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, Stdio},
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/// Scheduling priority requested for the child. `Normal` (the default) means "inherit": it
33/// leaves the spawn exactly as it was before this option existed, so the child keeps the
34/// parent's priority class (Windows) or nice value (Unix). It does not reset a lowered parent
35/// back to normal. The other values only lower priority, which needs no
36/// privilege and never raises a value the child already inherits above a lower one.
37///
38/// Windows: `BelowNormal` adds `BELOW_NORMAL_PRIORITY_CLASS` and `Idle` adds
39/// `IDLE_PRIORITY_CLASS` to the creation flags. Unix: the child's nice value is raised to at
40/// least 10 (`BelowNormal`) or 19 (`Idle`) between fork and exec.
41///
42/// `Efficient` is `BelowNormal` plus the platform's energy-efficiency class, for background
43/// work that should not wake the fast cores. Windows: `BELOW_NORMAL_PRIORITY_CLASS` plus
44/// EcoQoS (`PROCESS_POWER_THROTTLING_EXECUTION_SPEED` through `SetProcessInformation`, set
45/// while the child is still suspended; a Windows without EcoQoS ignores it, best effort).
46/// macOS: nice 10 and the `QOS_CLASS_UTILITY` quality of service on the child's initial
47/// thread (set between fork and exec; advisory). Other Unix: nice 10.
48#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
49#[non_exhaustive]
50pub enum ProcessPriority {
51    #[default]
52    Normal,
53    BelowNormal,
54    Idle,
55    Efficient,
56}
57
58impl ProcessPriority {
59    /// Minimum Unix nice value for this priority; `None` leaves the inherited value alone.
60    #[cfg(unix)]
61    pub(crate) fn nice_floor(self) -> Option<libc::c_int> {
62        match self {
63            Self::Normal => None,
64            Self::BelowNormal | Self::Efficient => Some(10),
65            Self::Idle => Some(19),
66        }
67    }
68}
69
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
71pub struct TerminationOptions {
72    /// Send TERM immediately on Unix, then KILL after this tree-wide grace.
73    pub grace: Duration,
74    /// Passed to TerminateJobObject/TerminateProcess on Windows.
75    pub windows_exit_code: u32,
76}
77impl Default for TerminationOptions {
78    fn default() -> Self {
79        Self {
80            grace: Duration::ZERO,
81            windows_exit_code: 1,
82        }
83    }
84}
85impl TerminationOptions {
86    pub fn with_grace(mut self, grace: Duration) -> Self {
87        self.grace = grace;
88        self
89    }
90    pub fn with_windows_exit_code(mut self, code: u32) -> Self {
91        self.windows_exit_code = code;
92        self
93    }
94}
95
96/// Every failed assignment in a best-effort Windows spawn is retained.
97/// Windows job breakaway policy for an owned child; Unix ignores it.
98#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
99pub(crate) enum BreakawayMode {
100    /// Full containment.
101    #[default]
102    None,
103    /// `JOB_OBJECT_LIMIT_BREAKAWAY_OK`: only processes that ask leave the job.
104    Explicit,
105    /// `BREAKAWAY_OK` plus `SILENT_BREAKAWAY_OK`: every created process leaves the job.
106    Silent,
107}
108
109#[derive(Debug)]
110pub struct JobAssignmentFailure {
111    pub stage: &'static str,
112    pub error: io::Error,
113}
114impl JobAssignmentFailure {
115    pub fn raw_os_error(&self) -> Option<i32> {
116        self.error.raw_os_error()
117    }
118}
119
120/// Borrowed OS handle/fd value; keep it valid until spawn returns.
121#[cfg(windows)]
122pub type RawHandleOrFd = std::os::windows::io::RawHandle;
123#[cfg(unix)]
124pub type RawHandleOrFd = std::os::fd::RawFd;
125#[cfg(windows)]
126type InheritedValue = usize;
127#[cfg(unix)]
128type InheritedValue = RawHandleOrFd;
129
130#[derive(Debug)]
131pub struct OwnedCommand {
132    command: Command,
133    windows_hidden: bool,
134    windows_breakaway: BreakawayMode,
135    caller_job: CallerJob,
136    job_assignment: JobAssignmentMode,
137    unix_containment: UnixContainment,
138    process_priority: ProcessPriority,
139    kill_on_parent_exit: bool,
140    // Set by `spawn_suspended`: Windows leaves the child suspended until `OwnedChild::resume`.
141    hold_suspended: bool,
142    // None preserves the existing unbounded partial-spawn reap.
143    partial_spawn_cleanup_timeout: Option<Duration>,
144    inherited_handles: Option<Vec<InheritedValue>>,
145    // Unix: descriptors that stay open in the child on top of `inherited_handles`.
146    #[cfg(unix)]
147    inherited_fds: Vec<std::os::fd::RawFd>,
148    // Opt-out of descriptor/handle sanitation (Orthic-Labs/rightkit#835).
149    inherit_all_descriptors: bool,
150    #[cfg(windows)]
151    windows_spawn_config: Option<crate::WindowsSpawnConfig>,
152    #[cfg(windows)]
153    windows_spawn_config_auto: bool,
154    // Set by `pipe_stdio` (the `run` family). Applied at spawn to BOTH launch paths, after every
155    // caller edit, so a later `command_mut` cannot undo it.
156    run_stdio: Option<RunStdio>,
157    env_cleared: bool,
158}
159
160/// The piped stdio contract of the bounded runners: stdout and stderr are always piped, stdin
161/// is piped when the caller has input to write and null otherwise.
162#[derive(Clone, Copy, Debug)]
163struct RunStdio {
164    stdin_piped: bool,
165}
166#[cfg(windows)]
167type CallerJob = Option<std::os::windows::io::OwnedHandle>;
168#[cfg(not(windows))]
169type CallerJob = Option<std::convert::Infallible>;
170
171impl OwnedCommand {
172    pub fn new(program: impl AsRef<OsStr>) -> Self {
173        let command = Self::from_command(Command::new(program));
174        #[cfg(windows)]
175        let command = {
176            let mut command = command;
177            // Only a fresh Command has known inherited stdio/environment defaults. The
178            // config is marked auto, and `command_mut` clears it.
179            command.windows_spawn_config = Some(crate::WindowsSpawnConfig::default());
180            command.windows_spawn_config_auto = true;
181            command
182        };
183        command
184    }
185    /// GUI/service children are hidden on Windows by default. A wrapped `Command` may carry
186    /// stdio, environment and raw-argument settings this crate cannot read back, so on
187    /// Windows an allowlisted spawn (`inherit_handles_only`) from `from_command` requires an
188    /// explicit `windows_spawn_config`. The crate never widens what a child inherits by
189    /// default.
190    pub fn from_command(command: Command) -> Self {
191        Self {
192            command,
193            windows_hidden: true,
194            windows_breakaway: BreakawayMode::None,
195            caller_job: None,
196            job_assignment: JobAssignmentMode::Strict,
197            unix_containment: UnixContainment::ProcessGroup,
198            process_priority: ProcessPriority::Normal,
199            kill_on_parent_exit: false,
200            hold_suspended: false,
201            partial_spawn_cleanup_timeout: None,
202            inherited_handles: None,
203            #[cfg(unix)]
204            inherited_fds: Vec::new(),
205            inherit_all_descriptors: false,
206            #[cfg(windows)]
207            windows_spawn_config: None,
208            #[cfg(windows)]
209            windows_spawn_config_auto: false,
210            run_stdio: None,
211            env_cleared: false,
212        }
213    }
214    /// Crate-private: the one place that decides the stdio of a bounded run. It records the
215    /// request instead of editing `Command` now, and `spawn` applies it to the std `Command`
216    /// and, for an allowlisted Windows spawn, to the native [`WindowsSpawnConfig`] (explicit or
217    /// auto) in one step. It deliberately does not call [`command_mut`](Self::command_mut), which
218    /// would clear an auto config. Environment, working directory, raw arguments and every
219    /// security setting are left as the caller configured them.
220    pub(crate) fn pipe_stdio(&mut self, stdin_piped: bool) -> &mut Self {
221        self.run_stdio = Some(RunStdio { stdin_piped });
222        self
223    }
224    pub fn command_mut(&mut self) -> &mut Command {
225        #[cfg(windows)]
226        if self.windows_spawn_config_auto {
227            // A mutable std Command can change settings with no stable getter.
228            self.windows_spawn_config = None;
229        }
230        &mut self.command
231    }
232    /// Inherit only these handles/fds plus configured stdio; values stay caller-owned.
233    pub fn inherit_handles_only(
234        &mut self,
235        handles: impl IntoIterator<Item = RawHandleOrFd>,
236    ) -> &mut Self {
237        #[cfg(windows)]
238        let handles = handles.into_iter().map(|handle| handle as usize).collect();
239        #[cfg(unix)]
240        let handles = handles.into_iter().collect();
241        self.inherited_handles = Some(handles);
242        self
243    }
244    /// Unix: keeps descriptor `fd` open in the child, in addition to stdio and anything listed
245    /// through [`inherit_handles_only`](Self::inherit_handles_only) or other `inherit_fd(s)`
246    /// calls. By default a spawned child inherits no descriptor above stderr: every one is
247    /// marked close-on-exec between `fork` and `exec` (Orthic-Labs/rightkit#835). Listing `fd`
248    /// clears that flag in the child only; the parent's flags are not changed. The descriptor
249    /// must be open when [`spawn`](Self::spawn) runs, else the spawn fails with `EBADF`, and
250    /// stays caller-owned. Use this, not a `pre_exec` of your own on
251    /// [`command_mut`](Self::command_mut): the sweep is registered at spawn, after every hook
252    /// you added, and would close what such a hook opened. Descriptors 0 to 2 are stdio and
253    /// follow the command's stdio settings. Not available on Windows.
254    #[cfg(unix)]
255    pub fn inherit_fd(&mut self, fd: std::os::fd::RawFd) -> &mut Self {
256        self.inherited_fds.push(fd);
257        self
258    }
259    /// Unix: [`inherit_fd`](Self::inherit_fd) for each descriptor in `fds`.
260    #[cfg(unix)]
261    pub fn inherit_fds(&mut self, fds: &[std::os::fd::RawFd]) -> &mut Self {
262        self.inherited_fds.extend_from_slice(fds);
263        self
264    }
265    /// Opts out of descriptor sanitation. Unsafe by policy: with `true`, a Unix child inherits
266    /// every descriptor of the parent that is not itself close-on-exec, a sandboxed child
267    /// included, which is exactly what a sandbox must not allow (Orthic-Labs/rightkit#835).
268    /// On Windows it keeps the ordinary `CreateProcess` with handle inheritance on and no
269    /// handle list, which hands the child every inheritable handle of the parent. Only a caller
270    /// that passes descriptors it cannot name (a launcher forwarding an unknown inherited set)
271    /// has a reason. Conflicts with [`inherit_handles_only`](Self::inherit_handles_only) and
272    /// `inherit_fd(s)`: the spawn fails with `InvalidInput` instead of guessing which wins.
273    pub fn inherit_all_descriptors(&mut self, enabled: bool) -> &mut Self {
274        self.inherit_all_descriptors = enabled;
275        self
276    }
277    /// Supply opaque stdio/environment/raw-arg settings for native allowlisted spawns.
278    /// Required after from_command/command_mut when inherit_handles_only is set.
279    /// Ordinary std spawns ignore it; fresh new() Commands have known defaults. The bounded
280    /// runners (`run`, `run_command`, `run_streaming`) replace its stdin, stdout and stderr with
281    /// the pipes they capture, whether the config is explicit or automatic, and keep the rest.
282    #[cfg(windows)]
283    pub fn windows_spawn_config(&mut self, config: crate::WindowsSpawnConfig) -> &mut Self {
284        self.windows_spawn_config = Some(config);
285        self.windows_spawn_config_auto = false;
286        self
287    }
288    /// Clear environment, then copy only named variables set in this process.
289    pub fn env_allowlist<I, S>(&mut self, keys: I) -> &mut Self
290    where
291        I: IntoIterator<Item = S>,
292        S: AsRef<OsStr>,
293    {
294        let kept: Vec<_> = keys
295            .into_iter()
296            .filter_map(|k| std::env::var_os(k.as_ref()).map(|v| (k.as_ref().to_os_string(), v)))
297            .collect();
298        self.command.env_clear();
299        self.env_cleared = true;
300        for (k, v) in kept {
301            self.command.env(k, v);
302        }
303        self
304    }
305    /// [`Self::env_allowlist`] over [`DEFAULT_ENV_ALLOWLIST`] plus `extra` (a consumer's own
306    /// variables, for instance a model-cache location). Call it once: like `env_allowlist` it
307    /// starts from an empty environment, so a second call drops what the first kept.
308    pub fn env_allowlist_with_defaults<I, S>(&mut self, extra: I) -> &mut Self
309    where
310        I: IntoIterator<Item = S>,
311        S: AsRef<OsStr>,
312    {
313        let keys: Vec<std::ffi::OsString> = DEFAULT_ENV_ALLOWLIST
314            .iter()
315            .map(std::ffi::OsString::from)
316            .chain(extra.into_iter().map(|k| k.as_ref().to_os_string()))
317            .collect();
318        self.env_allowlist(keys)
319    }
320    /// True once [`Self::env_allowlist`] (or `env_allowlist_with_defaults`) cleared the inherited
321    /// environment on this command. A `Command` that was cleared with `env_clear` directly, or
322    /// wrapped by `from_command` after it was cleared, reads `false`: the state is not observable
323    /// from `std::process::Command`.
324    pub fn env_is_cleared(&self) -> bool {
325        self.env_cleared
326    }
327    pub fn env_strip<I, S>(&mut self, keys: I) -> &mut Self
328    where
329        I: IntoIterator<Item = S>,
330        S: AsRef<OsStr>,
331    {
332        for k in keys {
333            self.command.env_remove(k);
334        }
335        self
336    }
337    /// Compatibility no-op: hidden is already the default.
338    pub fn windows_hide(&mut self) -> &mut Self {
339        self.windows_hidden = true;
340        self
341    }
342    /// Windows: leaves the console window visible, clearing the hidden default, for a
343    /// user-facing program (an editor, a terminal at a project path) that must keep its window.
344    /// Pair it with [`spawn_uncontained_detached`](Self::spawn_uncontained_detached) for a
345    /// fire-and-forget launch. Unix ignores it.
346    pub fn windows_visible(&mut self) -> &mut Self {
347        self.windows_hidden = false;
348        self
349    }
350    /// Windows: explicit-only breakaway (`JOB_OBJECT_LIMIT_BREAKAWAY_OK`). A process in the
351    /// job leaves it only by asking (`CREATE_BREAKAWAY_FROM_JOB`); every descendant that does
352    /// not ask stays contained and is terminated with the job. Dropping the owned child still
353    /// terminates the helper's tree; a grandchild that asked and broke away stays running.
354    /// `false` restores full containment. Unix ignores it. For descendants that escape without
355    /// asking, see [`windows_allow_silent_breakaway`](Self::windows_allow_silent_breakaway).
356    pub fn windows_allow_breakaway(&mut self, allow: bool) -> &mut Self {
357        self.windows_breakaway = if allow {
358            BreakawayMode::Explicit
359        } else {
360            BreakawayMode::None
361        };
362        self
363    }
364    /// Windows: silent breakaway (`JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK` plus `BREAKAWAY_OK`).
365    /// Every process the job's processes create is outside the job without asking, so
366    /// descendants are NOT contained and survive the helper. Use only for a helper whose
367    /// children must all outlive it. `false` restores full containment. Unix ignores it.
368    pub fn windows_allow_silent_breakaway(&mut self, allow: bool) -> &mut Self {
369        self.windows_breakaway = if allow {
370            BreakawayMode::Silent
371        } else {
372            BreakawayMode::None
373        };
374        self
375    }
376    /// Duplicate caller job; assign before the empty nested RightKit job.
377    #[cfg(windows)]
378    pub fn windows_job(
379        &mut self,
380        job: std::os::windows::io::BorrowedHandle<'_>,
381    ) -> io::Result<&mut Self> {
382        self.caller_job = Some(job.try_clone_to_owned()?);
383        Ok(self)
384    }
385    /// Ignored on Unix. BestEffort reports errors on OwnedChild.
386    pub fn job_assignment_mode(&mut self, mode: JobAssignmentMode) -> &mut Self {
387        self.job_assignment = mode;
388        self
389    }
390    /// Ignored on Windows. Session runs setsid before exec.
391    pub fn unix_containment(&mut self, mode: UnixContainment) -> &mut Self {
392        self.unix_containment = mode;
393        self
394    }
395    /// Scheduling priority for the child. The default, `ProcessPriority::Normal`, adds nothing
396    /// to the spawn. See `ProcessPriority` for the Windows and Unix mappings. Applies to
397    /// detached spawns too.
398    pub fn process_priority(&mut self, priority: ProcessPriority) -> &mut Self {
399        self.process_priority = priority;
400        self
401    }
402    /// Opt-in, off by default. Linux only: the kernel sends `SIGTERM` to the direct child
403    /// (`PR_SET_PDEATHSIG`) when the thread that spawned it exits, which includes this
404    /// process dying without running cleanup (for example SIGKILL). The signal reaches the
405    /// direct child only, never the rest of its group, it can be caught, and there is no
406    /// `SIGKILL` escalation. It is keyed to the spawning *thread*, so spawn from a thread
407    /// that lives as long as the child.
408    ///
409    /// macOS has no `PR_SET_PDEATHSIG`, so the tie is a small watchdog process started right
410    /// after the child (a `kqueue` with `EVFILT_PROC`/`NOTE_EXIT` on this process and on the
411    /// child; see `process_unix::arm_parent_death_watch`). When this process dies, SIGKILL
412    /// included, the watchdog sends `SIGTERM` to the child's whole process group and `SIGKILL`
413    /// two seconds later. Differences from Linux: the trigger is the whole process exiting,
414    /// not the spawning thread; the signal goes to the group and escalates; it costs one
415    /// extra helper process per tied child (it exits when the child or this process does);
416    /// the child runs untied between its spawn and the moment `spawn` returns (a few
417    /// milliseconds), and `spawn` kills the child and fails if the watchdog cannot be armed.
418    ///
419    /// Other Unix hosts have no parent-death primitive this crate relies on: the spawn fails
420    /// with `ErrorKind::Unsupported` before anything is started, rather than run the child
421    /// untied. Windows ignores the option because kill-on-close job objects already cover
422    /// parent death. Rejected (`InvalidInput`) for detached spawns.
423    pub fn kill_on_parent_exit(&mut self, enabled: bool) -> &mut Self {
424        self.kill_on_parent_exit = enabled;
425        self
426    }
427    /// Bound failed-spawn cleanup. Timed-out children go to a background reaper.
428    pub fn partial_spawn_cleanup_timeout(&mut self, timeout: Duration) -> &mut Self {
429        self.partial_spawn_cleanup_timeout = Some(timeout);
430        self
431    }
432    pub(crate) fn bound_runner_cleanup(&mut self, timeout: Duration) {
433        self.partial_spawn_cleanup_timeout = Some(
434            self.partial_spawn_cleanup_timeout
435                .map_or(timeout, |current| current.min(timeout)),
436        );
437    }
438    pub fn spawn(self) -> io::Result<OwnedChild> {
439        self.spawn_inner(false, false, None)
440    }
441    /// Like [`OwnedCommand::spawn`], but on Windows the child is created suspended, assigned to
442    /// its Job Object, and held there: it executes no instruction until
443    /// [`OwnedChild::resume`] is called. A caller can persist the child's identity (pid and
444    /// start time) first, then release it. Dropping a child that was never resumed terminates it
445    /// like any owned child.
446    ///
447    /// Unix has no suspended creation: this is `spawn`, the child runs at once, and `resume` is
448    /// a no-op that returns `Ok`. A caller that needs a hold on Unix gates the child itself (for
449    /// example a pipe the child blocks reading).
450    pub fn spawn_suspended(mut self) -> io::Result<OwnedChild> {
451        self.hold_suspended = true;
452        self.spawn_inner(false, false, None)
453    }
454    /// Shared launch with no owner job or terminate-on-drop. Unix uses setsid.
455    /// Stdio follows Command: dropping piped stdin can still deliver EOF.
456    ///
457    /// Windows first asks to break away from the caller's job object
458    /// (`CREATE_BREAKAWAY_FROM_JOB`). If the caller's job denies breakaway
459    /// (`ERROR_ACCESS_DENIED`), the launch is retried once without it, as
460    /// `CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP` (`DETACHED_PROCESS` if `show_window`) with the same stdio and handle
461    /// allowlist (an ordinary spawn without an allowlist still inherits what std inherits).
462    /// The child is then detached from the console and process group but stays inside the
463    /// caller's job, so the job's limits (including kill-on-close) still reach it. Check
464    /// [`OwnedChild::stayed_in_parent_job`] to learn which happened. A caller that must not
465    /// run inside the parent's job uses
466    /// [`spawn_uncontained_detached_strict`](Self::spawn_uncontained_detached_strict). Any
467    /// other launch failure is returned as is, without a retry. Unix behaviour is unchanged.
468    pub fn spawn_uncontained_detached(self) -> io::Result<OwnedChild> {
469        self.spawn_inner_with(true, false, None, true)
470    }
471    /// Like [`spawn_uncontained_detached`](Self::spawn_uncontained_detached), but never falls
472    /// back. On Windows, when the caller's job denies breakaway the launch fails with an
473    /// [`io::Error`] of kind `PermissionDenied` wrapping [`BreakawayDenied`] (recover it with
474    /// `error.get_ref().and_then(|e| e.downcast_ref::<BreakawayDenied>())`), and nothing is
475    /// started. On Unix this is identical to `spawn_uncontained_detached`.
476    pub fn spawn_uncontained_detached_strict(self) -> io::Result<OwnedChild> {
477        match self.spawn_inner_with(true, false, None, false) {
478            Err(error) if cfg!(windows) && is_denied_breakaway(&error) => Err(io::Error::new(
479                io::ErrorKind::PermissionDenied,
480                BreakawayDenied,
481            )),
482            other => other,
483        }
484    }
485    fn spawn_inner(
486        self,
487        detached: bool,
488        fail_after_spawn: bool,
489        captured_pid: Option<Arc<std::sync::atomic::AtomicU32>>,
490    ) -> io::Result<OwnedChild> {
491        self.spawn_inner_with(detached, fail_after_spawn, captured_pid, false)
492    }
493    fn spawn_inner_with(
494        mut self,
495        detached: bool,
496        fail_after_spawn: bool,
497        captured_pid: Option<Arc<std::sync::atomic::AtomicU32>>,
498        in_job_fallback: bool,
499    ) -> io::Result<OwnedChild> {
500        if detached && self.caller_job.is_some() {
501            return Err(io::Error::new(
502                io::ErrorKind::InvalidInput,
503                "detached launch cannot join a caller job",
504            ));
505        }
506        if detached && self.kill_on_parent_exit {
507            return Err(io::Error::new(
508                io::ErrorKind::InvalidInput,
509                "kill_on_parent_exit cannot combine with a detached launch",
510            ));
511        }
512        #[cfg(unix)]
513        let lists_fds = !self.inherited_fds.is_empty();
514        #[cfg(not(unix))]
515        let lists_fds = false;
516        if self.inherit_all_descriptors && (self.inherited_handles.is_some() || lists_fds) {
517            return Err(io::Error::new(
518                io::ErrorKind::InvalidInput,
519                "inherit_all_descriptors conflicts with inherit_handles_only and inherit_fd(s)",
520            ));
521        }
522        // Windows: a command whose stdio and environment are known (`new`, or an explicit
523        // config) takes the native spawn with an empty handle list by default, so the child
524        // inherits its stdio and nothing else. A bare `from_command` has no config and keeps
525        // std's spawn (see the README).
526        #[cfg(windows)]
527        if !self.inherit_all_descriptors
528            && self.inherited_handles.is_none()
529            && self.windows_spawn_config.is_some()
530        {
531            self.inherited_handles = Some(Vec::new());
532        }
533        if let Some(RunStdio { stdin_piped }) = self.run_stdio {
534            // Applied here, after every caller edit, to both launch paths: the std spawn reads
535            // the `Command`, an allowlisted Windows spawn reads only the config.
536            self.command
537                .stdin(if stdin_piped {
538                    Stdio::piped()
539                } else {
540                    Stdio::null()
541                })
542                .stdout(Stdio::piped())
543                .stderr(Stdio::piped());
544            #[cfg(windows)]
545            if let Some(config) = self.windows_spawn_config.as_mut() {
546                config.stdin = if stdin_piped {
547                    crate::WindowsStdio::Piped
548                } else {
549                    crate::WindowsStdio::Null
550                };
551                config.stdout = crate::WindowsStdio::Piped;
552                config.stderr = crate::WindowsStdio::Piped;
553            }
554        }
555        #[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
556        if self.kill_on_parent_exit {
557            return Err(io::Error::new(
558                io::ErrorKind::Unsupported,
559                "kill_on_parent_exit is supported on Linux and macOS only",
560            ));
561        }
562        #[cfg(unix)]
563        {
564            use std::os::unix::process::CommandExt;
565            let session = detached || self.unix_containment == UnixContainment::Session;
566            let nice_floor = self.process_priority.nice_floor();
567            #[cfg(target_os = "macos")]
568            let utility_qos = self.process_priority == ProcessPriority::Efficient;
569            // Default: sweep every descriptor above stderr (Orthic-Labs/rightkit#835); the
570            // allowlist and `inherit_fd(s)` are the exceptions. Built here, in the parent.
571            let inheritance = if self.inherit_all_descriptors {
572                None
573            } else {
574                let mut allowed = self.inherited_handles.take().unwrap_or_default();
575                allowed.extend(self.inherited_fds.iter().copied());
576                Some(crate::process_unix::Inheritance::new(allowed)?)
577            };
578            // The supervisor's pid, read here and compared in the child (see `die_with_parent`).
579            #[cfg(target_os = "linux")]
580            let supervisor = self
581                .kill_on_parent_exit
582                // SAFETY: getpid has no preconditions.
583                .then(|| unsafe { libc::getpid() });
584            // Only async-signal-safe libc calls run between fork and exec.
585            unsafe {
586                self.command.pre_exec(move || {
587                    let result = if session {
588                        libc::setsid()
589                    } else {
590                        libc::setpgid(0, 0)
591                    };
592                    if result < 0 {
593                        return Err(io::Error::last_os_error());
594                    }
595                    if let Some(floor) = nice_floor {
596                        crate::process_unix::raise_nice_to(floor)?;
597                    }
598                    #[cfg(target_os = "macos")]
599                    if utility_qos {
600                        crate::process_unix::set_utility_qos();
601                    }
602                    if let Some(inheritance) = &inheritance {
603                        inheritance.apply()?;
604                    }
605                    #[cfg(target_os = "linux")]
606                    if let Some(supervisor) = supervisor {
607                        crate::process_unix::die_with_parent(supervisor)?;
608                    }
609                    Ok(())
610                });
611            }
612        }
613        #[cfg(windows)]
614        crate::process_windows::configure(
615            &mut self.command,
616            detached,
617            self.windows_hidden,
618            self.process_priority,
619        );
620        #[cfg(unix)]
621        let _ = (
622            self.windows_hidden,
623            self.windows_breakaway,
624            self.job_assignment,
625        );
626        #[cfg(windows)]
627        let _ = (self.unix_containment, self.kill_on_parent_exit);
628        let spawned_at = SystemTime::now();
629        let hold_suspended = self.hold_suspended && !detached;
630        #[cfg(unix)]
631        let _ = hold_suspended;
632        #[cfg(unix)]
633        let child = {
634            let mut child = crate::unix_child::UnixChild::new(self.command.spawn()?);
635            // A child whose pid cannot be tied to it is stopped here, while the pid is
636            // certainly still its own: no later signal could be proven to reach it.
637            if let Err(error) = child.require_identity() {
638                let reaped = child.cleanup_unidentified(self.partial_spawn_cleanup_timeout);
639                return Err(spawn_cleanup_failure(error, reaped));
640            }
641            // macOS: the tie is a watchdog armed while the pid is still certainly ours. A
642            // child that could not be tied is stopped, never handed out untied.
643            #[cfg(target_os = "macos")]
644            if self.kill_on_parent_exit {
645                if let Err(error) = crate::process_unix::arm_parent_death_watch(child.id()) {
646                    let _ = child.signal(SignalTarget::Group, libc::SIGKILL);
647                    let reaped = cleanup_direct(
648                        Arc::new(Mutex::new(child)),
649                        self.partial_spawn_cleanup_timeout,
650                    );
651                    return Err(spawn_cleanup_failure(error, reaped));
652                }
653            }
654            child
655        };
656        #[cfg(windows)]
657        let (child, stayed_in_parent_job) = match self.inherited_handles.as_deref() {
658            None => {
659                // An ordinary spawn inherits every inheritable handle. Held so it never runs
660                // while an allowlisted spawn has private handles raised.
661                let _spawning = crate::spawn_windows::spawn_guard();
662                match self.command.spawn() {
663                    Ok(child) => (PlatformChild::from(child), false),
664                    // The caller's job denied breakaway: one retry, detached but inside it.
665                    Err(error)
666                        if detached
667                            && in_job_fallback
668                            && crate::process_windows::is_breakaway_denied(&error) =>
669                    {
670                        crate::process_windows::configure_in_job_detached(
671                            &mut self.command,
672                            self.windows_hidden,
673                            self.process_priority,
674                        );
675                        (PlatformChild::from(self.command.spawn()?), true)
676                    }
677                    Err(error) => return Err(error),
678                }
679            }
680            Some(handles) => {
681                let config = self.windows_spawn_config.take().ok_or_else(|| {
682                    io::Error::new(io::ErrorKind::InvalidInput,
683                        "allowlisted Windows spawn requires windows_spawn_config for opaque Command settings")
684                })?;
685                crate::spawn_windows::spawn(
686                    &self.command,
687                    config,
688                    handles,
689                    detached,
690                    self.windows_hidden,
691                    self.process_priority,
692                    self.env_cleared,
693                    self.partial_spawn_cleanup_timeout,
694                    in_job_fallback,
695                )?
696            }
697        };
698        #[cfg(not(windows))]
699        let _ = in_job_fallback;
700        #[cfg(not(windows))]
701        let stayed_in_parent_job = false;
702        let pid = child.id();
703        if let Some(captured) = captured_pid {
704            captured.store(pid, std::sync::atomic::Ordering::SeqCst);
705        }
706        let child = Arc::new(Mutex::new(child));
707        let handle = match ProcessHandle::new(&child, spawned_at) {
708            Ok(handle) => handle,
709            Err(error) => {
710                let reaped = cleanup_direct(child, self.partial_spawn_cleanup_timeout);
711                return Err(spawn_cleanup_failure(error, reaped));
712            }
713        };
714        let mut partial = PartialChild {
715            child: Some(OwnedChild {
716                child,
717                handle,
718                pid,
719                detached,
720                terminate_on_drop: !detached,
721                drop_reap_timeout: DEFAULT_DROP_REAP_TIMEOUT,
722                tree_terminated: false,
723                assignment_failures: Vec::new(),
724                suspended: std::sync::atomic::AtomicBool::new(cfg!(windows) && hold_suspended),
725                stayed_in_parent_job,
726                #[cfg(windows)]
727                job: None,
728            }),
729            timeout: self.partial_spawn_cleanup_timeout,
730        };
731        #[cfg(windows)]
732        if !detached {
733            let owned = partial.child.as_mut().expect("partial child is present");
734            let assignment = crate::process_windows::assign_jobs(
735                &owned.handle,
736                self.caller_job.take(),
737                self.job_assignment,
738                self.windows_breakaway,
739                &mut owned.job,
740                &mut owned.assignment_failures,
741            );
742            if let Err(error) = assignment {
743                return Err(partial.fail(error));
744            }
745        }
746        #[cfg(windows)]
747        {
748            let owned = partial.child.as_ref().expect("partial child is present");
749            if self.process_priority == ProcessPriority::Efficient {
750                // Best effort: EcoQoS is a hint and older Windows builds lack it.
751                let _ = crate::process_windows::apply_efficiency(&*owned.child());
752            }
753            if !hold_suspended {
754                let resumed = owned.child().resume(detached);
755                if let Err(error) = resumed {
756                    return Err(partial.fail(error));
757                }
758            }
759        }
760        if fail_after_spawn {
761            return Err(partial.fail(io::Error::other("injected post-spawn wrapping failure")));
762        }
763        let owned = partial.child.take().expect("partial child is present");
764        if !detached {
765            // Detached children are deliberately not owned: they outlive their handle.
766            #[cfg(unix)]
767            crate::registry::register(&owned.child, owned.pid);
768            #[cfg(windows)]
769            crate::registry::register(
770                &owned.child,
771                &owned.handle,
772                owned
773                    .job
774                    .as_ref()
775                    .map_or_else(std::sync::Weak::new, std::sync::Arc::downgrade),
776                owned.pid,
777            );
778        }
779        Ok(owned)
780    }
781}
782
783/// The error source returned (inside an [`io::Error`] of kind `PermissionDenied`) by
784/// [`OwnedCommand::spawn_uncontained_detached_strict`] on Windows when the caller's job object
785/// denies `CREATE_BREAKAWAY_FROM_JOB`. The OS reports this as `ERROR_ACCESS_DENIED`, which this
786/// crate cannot tell apart from other access-denied launch failures; a job that forbids
787/// breakaway is the cause this crate expects.
788#[derive(Clone, Copy, Debug, Eq, PartialEq)]
789pub struct BreakawayDenied;
790
791impl std::fmt::Display for BreakawayDenied {
792    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
793        f.write_str(
794            "detached launch denied: the caller's job object does not allow breakaway \
795             (ERROR_ACCESS_DENIED); no fallback was attempted",
796        )
797    }
798}
799
800impl std::error::Error for BreakawayDenied {}
801
802#[cfg(windows)]
803fn is_denied_breakaway(error: &io::Error) -> bool {
804    crate::process_windows::is_breakaway_denied(error)
805}
806#[cfg(not(windows))]
807fn is_denied_breakaway(_error: &io::Error) -> bool {
808    false
809}
810
811/// How long `Drop` waits for a killed leader to be reaped before handing the reap to a
812/// background thread.
813pub const DEFAULT_DROP_REAP_TIMEOUT: Duration = Duration::from_secs(2);
814
815pub const DEFAULT_ENV_ALLOWLIST: &[&str] = &[
816    "PATH",
817    "HOME",
818    "USER",
819    "LOGNAME",
820    "LANG",
821    "LC_ALL",
822    "TMPDIR",
823    "TEMP",
824    "TMP",
825    "SystemRoot",
826    "SYSTEMROOT",
827    "USERPROFILE",
828    "APPDATA",
829    "LOCALAPPDATA",
830    "ProgramData",
831    "COMSPEC",
832    "PATHEXT",
833    "XDG_DATA_HOME",
834    "XDG_RUNTIME_DIR",
835];
836
837#[derive(Debug)]
838pub struct OwnedChild {
839    child: Arc<Mutex<PlatformChild>>,
840    handle: ProcessHandle,
841    // Captured before any wait can reap the direct child.
842    pid: u32,
843    detached: bool,
844    terminate_on_drop: bool,
845    drop_reap_timeout: Duration,
846    tree_terminated: bool,
847    assignment_failures: Vec<JobAssignmentFailure>,
848    // True from `spawn_suspended` (Windows) until `resume` releases the child.
849    suspended: std::sync::atomic::AtomicBool,
850    // Windows detached launch only: breakaway was denied and the child runs inside the parent's job.
851    stayed_in_parent_job: bool,
852    // Shared so the owned-children registry can reach the job without keeping it open
853    // (it holds a `Weak`): the kill-on-close job still closes with this child.
854    #[cfg(windows)]
855    job: Option<std::sync::Arc<crate::process_windows::KillOnCloseJob>>,
856}
857/// The result of [`OwnedChild::terminate_tree_deep`].
858#[derive(Clone, Debug, Eq, PartialEq)]
859pub struct TreeTermination {
860    /// The leader's exit status.
861    pub status: ExitStatus,
862    /// What happened to the descendants recorded before the leader was stopped.
863    pub descendants: crate::SweepReport,
864}
865#[derive(Clone, Copy, Debug, Eq, PartialEq)]
866pub enum ReapOutcome {
867    Exited(ExitStatus),
868    TimedOut,
869}
870#[derive(Clone, Copy, Debug, Eq, PartialEq)]
871pub enum WaitOutcome {
872    Exited(ExitStatus),
873    Terminated(ExitStatus),
874}
875
876impl OwnedChild {
877    pub fn id(&self) -> u32 {
878        self.pid
879    }
880    /// True only for a Windows [`OwnedCommand::spawn_uncontained_detached`] child that could
881    /// not break away because the caller's job object denied it, so it was launched detached
882    /// but still inside that job (the job's limits, such as kill-on-close, still reach it).
883    /// False for every other child, including every Unix child and any child that broke away.
884    pub fn stayed_in_parent_job(&self) -> bool {
885        self.stayed_in_parent_job
886    }
887    /// Borrow child state, including stdio, wait and try_wait. Windows uses a
888    /// native view with std pipe types for both spawn paths; Unix uses `UnixChild`, which
889    /// has the same members and does not expose the std `Child`. Release this guard
890    /// before another accessor or exit reader; it holds the shared reap lock.
891    ///
892    /// Unix: a `wait` or `try_wait` through the guard that reaps the leader permanently
893    /// ends process-group signalling for this child, exactly like `OwnedChild::wait`.
894    ///
895    /// Unix: `wait` through the guard holds the shared lock until the child has exited, because
896    /// the guard is the lock. A bounded wait on a `ProcessHandle` clone, `kill`,
897    /// `terminate_tree` and `Drop` on other threads queue behind it and can exceed their own
898    /// deadlines, as with `std::process::Child::wait` through this accessor in 0.3.3. Wait
899    /// through `OwnedChild::wait`/`wait_timeout` or a `ProcessHandle` when anything else must
900    /// stay live meanwhile. (Windows: `WindowsChild::wait` through the guard blocks in
901    /// `WaitForSingleObject` while holding the lock, as in 0.3.3.)
902    pub fn child(&self) -> MutexGuard<'_, PlatformChild> {
903        lock_child(&self.child)
904    }
905    /// Release a child created by [`OwnedCommand::spawn_suspended`]. It is already in its Job
906    /// Object; this lets it run. Returns `Ok` and does nothing for a child that is not held
907    /// (an ordinary spawn, a child already resumed, and every Unix child). On error the child
908    /// stays held and `resume` can be retried.
909    pub fn resume(&self) -> io::Result<()> {
910        if self
911            .suspended
912            .swap(false, std::sync::atomic::Ordering::SeqCst)
913        {
914            #[cfg(windows)]
915            if let Err(error) = self.child().resume(self.detached) {
916                self.suspended
917                    .store(true, std::sync::atomic::Ordering::SeqCst);
918                return Err(error);
919            }
920        }
921        Ok(())
922    }
923    /// True while a child from [`OwnedCommand::spawn_suspended`] has not been resumed.
924    pub fn is_suspended(&self) -> bool {
925        self.suspended.load(std::sync::atomic::Ordering::SeqCst)
926    }
927    /// Runs `act` with the leader's pid while the leader is proven to be this child and
928    /// unreaped, holding the shared lock so no reap can fall between the proof and `act`.
929    /// Returns `Ok(None)` without calling `act` once the leader has been reaped or its pid no
930    /// longer provably names it. For code that must signal by pid (a descendant sweep) and
931    /// would otherwise act on a number that may have been recycled. The lock is held for as
932    /// long as `act` runs (that is what keeps a reap out), so every other thread's wait,
933    /// including a bounded one, is delayed by that long: keep `act` short, and never call back
934    /// into this child from it. Windows has no recyclable-pid hazard here and always runs it.
935    pub fn while_unreaped<R>(&self, act: impl FnOnce(u32) -> R) -> io::Result<Option<R>> {
936        #[cfg(unix)]
937        {
938            let mut child = lock_child(&self.child);
939            if child.leader_state()? == LeaderState::Reaped {
940                return Ok(None);
941            }
942            Ok(Some(act(self.pid)))
943        }
944        #[cfg(windows)]
945        Ok(Some(act(self.pid)))
946    }
947    pub fn take_stdin(&mut self) -> Option<ChildStdin> {
948        self.child().stdin.take()
949    }
950    /// Closes the child's stdin (delivering EOF) without waiting. Idempotent: a child whose
951    /// stdin was taken or already closed is untouched. For a child that already exited this
952    /// is a harmless drop of the unused pipe end, never an error.
953    pub fn close_stdin(&mut self) {
954        drop(self.child().stdin.take());
955    }
956    pub fn take_stdout(&mut self) -> Option<ChildStdout> {
957        self.child().stdout.take()
958    }
959    pub fn take_stderr(&mut self) -> Option<ChildStderr> {
960        self.child().stderr.take()
961    }
962    pub fn job_assignment_failures(&self) -> &[JobAssignmentFailure] {
963        &self.assignment_failures
964    }
965    /// An independent exit reader. A monitor thread that only needs to learn that the leader
966    /// exited should call [`ProcessHandle::wait_exited`], which does not reap; `wait` reaps, and
967    /// on Unix that ends the owner's ability to signal descendants that outlive the leader.
968    pub fn process_handle(&self) -> io::Result<ProcessHandle> {
969        self.handle.try_clone()
970    }
971    pub fn creation_time(&self) -> Option<SystemTime> {
972        self.handle.creation_time()
973    }
974    #[cfg(windows)]
975    pub fn creation_time_ticks(&self) -> Option<u64> {
976        self.handle.creation_time_ticks()
977    }
978    pub fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
979        // Through the handle so the reap also seals the process group's state.
980        self.handle.try_wait()
981    }
982    // Preserve std Child::wait's stdin-close behavior. ProcessHandle performs
983    // native blocking waits & keeps Unix reap results in std's shared cache.
984    pub fn wait(&mut self) -> io::Result<ExitStatus> {
985        drop(self.child().stdin.take());
986        self.handle.wait()
987    }
988    pub fn wait_timeout(&mut self, timeout: Duration) -> io::Result<Option<ExitStatus>> {
989        self.handle.wait_timeout(timeout)
990    }
991    /// Asks the tree to stop, waits up to `grace` for the leader, then forces the tree down.
992    ///
993    /// Unix: TERM goes to the group while the leader is still unreaped. If the leader exits
994    /// within `grace` the result is `Exited`, but descendants that outlived it (or ignored
995    /// TERM) are killed before the leader is reaped, while it still pins the group id. If it
996    /// does not exit in time the whole group is killed and the result is `Terminated`.
997    ///
998    /// Windows: if the leader exits within `grace` the result is `Exited` with its status, but
999    /// the retained job is terminated first, so descendants that outlived it do not survive.
1000    pub fn wait_or_kill(&mut self, grace: Duration) -> io::Result<WaitOutcome> {
1001        self.wait_or_kill_inner(grace, true)
1002    }
1003    /// Like [`wait_or_kill`](Self::wait_or_kill) but without the opening TERM on Unix: the
1004    /// leader gets the whole `grace` to exit on its own (for example after its stdin was
1005    /// closed) before the tree is killed. A leader that exits in time is never signalled, so
1006    /// its status is its own; descendants are still killed before the reap. Windows has no
1007    /// TERM step, so it behaves exactly like `wait_or_kill`.
1008    pub fn wait_then_kill(&mut self, grace: Duration) -> io::Result<WaitOutcome> {
1009        self.wait_or_kill_inner(grace, false)
1010    }
1011    fn wait_or_kill_inner(&mut self, grace: Duration, term_first: bool) -> io::Result<WaitOutcome> {
1012        #[cfg(not(unix))]
1013        let _ = term_first;
1014        #[cfg(unix)]
1015        {
1016            if term_first && !self.tree_terminated {
1017                self.signal(libc::SIGTERM)?;
1018            }
1019            // Looks without reaping until the leader has exited or the grace has passed.
1020            if self.handle.wait_exited_timeout(grace)? {
1021                // Exited, not reaped yet: the zombie still pins the group id, so the
1022                // stragglers can be killed safely, and only then reaped. (If a handle clone
1023                // reaped it, the signal is a no-op, the cached status is returned and group
1024                // signalling is over.)
1025                self.force_kill(1)?;
1026                return self.wait().map(WaitOutcome::Exited);
1027            }
1028            self.terminate_tree().map(WaitOutcome::Terminated)
1029        }
1030        #[cfg(windows)]
1031        {
1032            if let Some(status) = self.wait_timeout(grace)? {
1033                // The leader left, but descendants may still be in the retained job (and hold
1034                // the pipes). Bring the job down before reporting; the leader's own status,
1035                // already collected, is what the caller gets.
1036                self.force_kill(1)?;
1037                return Ok(WaitOutcome::Exited(status));
1038            }
1039            self.terminate_tree().map(WaitOutcome::Terminated)
1040        }
1041    }
1042    /// Waits up to `timeout` for the leader to exit, without a signal. When it has, brings the
1043    /// rest of the tree down before reaping (the exited branch of
1044    /// [`wait_then_kill`](Self::wait_then_kill): Unix kills the group while the unreaped leader
1045    /// still pins its id, Windows terminates the retained job) and returns the leader's own
1046    /// status. `None` when it is still running. Lets a caller poll for exit between other work.
1047    pub(crate) fn wait_exit_then_sweep(
1048        &mut self,
1049        timeout: Duration,
1050    ) -> io::Result<Option<ExitStatus>> {
1051        if self.handle.wait_exited_timeout(timeout)? {
1052            self.force_kill(1)?;
1053            return self.wait().map(Some);
1054        }
1055        Ok(None)
1056    }
1057    /// Immediate forced termination, preserving the existing default.
1058    /// Windows: the retained job is signalled even after the parent was reaped.
1059    /// Unix: the group is signalled only while the leader is unreaped (see the type
1060    /// documentation); once any path has reaped it, only the reap result is returned.
1061    pub fn terminate_tree(&mut self) -> io::Result<ExitStatus> {
1062        self.force_kill(1)?;
1063        self.wait()
1064    }
1065    /// Unix: TERM now, tree-wide grace, then KILL, all while the leader is unreaped (an
1066    /// exited leader stays unreaped until this call reaps it, so descendants still receive
1067    /// the KILL). If another path already reaped the leader, nothing is signalled.
1068    /// Windows: immediately terminate retained job with supplied exit code.
1069    pub fn terminate_tree_with_options(
1070        &mut self,
1071        options: TerminationOptions,
1072    ) -> io::Result<ExitStatus> {
1073        self.start_termination(options)?;
1074        self.wait()
1075    }
1076    pub fn terminate_tree_bounded(&mut self, timeout: Duration) -> io::Result<ReapOutcome> {
1077        self.terminate_tree_bounded_with_options(timeout, TerminationOptions::default())
1078    }
1079    /// timeout covers grace plus reap; grace is clipped to timeout.
1080    pub fn terminate_tree_bounded_with_options(
1081        &mut self,
1082        timeout: Duration,
1083        mut options: TerminationOptions,
1084    ) -> io::Result<ReapOutcome> {
1085        let started = Instant::now();
1086        // One absolute deadline covers grace, kill, root reap and job emptiness: each step reads
1087        // what is left of it when it actually waits, so a gap between steps never buys a fresh
1088        // budget (rightkit#1260). `None` (a timeout too large for an Instant) is unbounded.
1089        let deadline = started.checked_add(timeout);
1090        let left = || {
1091            deadline.map_or(Duration::MAX, |d| {
1092                d.saturating_duration_since(Instant::now())
1093            })
1094        };
1095        options.grace = options.grace.min(timeout);
1096        self.start_termination_within(options, deadline)?;
1097        #[cfg(test)]
1098        if cleanup_test_fault::refuse(left()) {
1099            return Ok(ReapOutcome::TimedOut);
1100        }
1101        let status = match self.wait_timeout(left())? {
1102            Some(status) => status,
1103            None => return Ok(ReapOutcome::TimedOut),
1104        };
1105        // Windows: the leader's exit does not prove the tree is gone (TerminateJobObject is
1106        // asynchronous). Exited is reported only once the retained job holds no active process,
1107        // within the same deadline; otherwise the outcome stays unconfirmed (rightkit#1260). A
1108        // child with no retained job (detached or uncontained) has no tree to prove: its leader's
1109        // exit stands, as before.
1110        #[cfg(windows)]
1111        if let Some(job) = self.job.as_ref() {
1112            #[cfg(test)]
1113            cleanup_test_fault::handoff_delay();
1114            if !job.wait_empty_until(deadline)? {
1115                return Ok(ReapOutcome::TimedOut);
1116            }
1117        }
1118        Ok(ReapOutcome::Exited(status))
1119    }
1120    /// On-demand termination of the whole tree, for a child that is not terminated on drop (a
1121    /// [`spawn_uncontained_detached`](OwnedCommand::spawn_uncontained_detached) child whose
1122    /// helpers should die now) as well as for an ordinary one. Unlike
1123    /// [`terminate_tree_with_options`](Self::terminate_tree_with_options), a detached child's
1124    /// descendants are not left alone:
1125    ///
1126    /// 1. While the leader is proven to be this child, the identities of its descendants are
1127    ///    recorded (parent links; descendants that already outlived a parent that exited are
1128    ///    not findable, as for [`descendant_identities`](Self::descendant_identities)).
1129    /// 2. Unix: `SIGTERM` to the leader's whole process group (a detached child leads its own
1130    ///    session, so that group is its own), up to `options.grace` for the group to leave,
1131    ///    then `SIGKILL` to the group, all before the leader is reaped, so the group id cannot
1132    ///    be recycled. Windows: the retained job if there is one (ordinary children), and the
1133    ///    leader (`options.windows_exit_code`).
1134    /// 3. The leader is reaped.
1135    /// 4. The recorded descendants that are still the same process (pid and start time; a
1136    ///    recycled pid is refused, never signalled) are swept, leaves first, with
1137    ///    `options.grace`. This is what reaches a helper that left the group (a daemon that
1138    ///    called `setsid`) and, on Windows, the descendants of a detached child.
1139    ///
1140    /// If another path already reaped the leader on Unix, nothing is signalled and the cached
1141    /// status is returned with an empty sweep. No terminate-on-drop is implied: calling this
1142    /// or not does not change what `Drop` does.
1143    pub fn terminate_tree_deep(
1144        &mut self,
1145        options: TerminationOptions,
1146    ) -> io::Result<TreeTermination> {
1147        let mut found = self.descendant_identities()?;
1148        found.reverse(); // leaves first
1149        #[cfg(unix)]
1150        if !self.tree_terminated {
1151            signal_shared(&self.child, SignalTarget::Group, libc::SIGTERM)?;
1152            await_tree_shared(&self.child, &self.handle, options.grace, true)?;
1153            signal_shared(&self.child, SignalTarget::Group, libc::SIGKILL)?;
1154            self.tree_terminated = true;
1155        }
1156        #[cfg(windows)]
1157        self.force_kill(options.windows_exit_code)?;
1158        let status = self.wait()?;
1159        let descendants = crate::sweep(&found, options.grace)?;
1160        Ok(TreeTermination {
1161            status,
1162            descendants,
1163        })
1164    }
1165    fn start_termination(&mut self, options: TerminationOptions) -> io::Result<()> {
1166        self.start_termination_within(options, None)
1167    }
1168    /// [`start_termination`](Self::start_termination) before `deadline`: the grace is already
1169    /// clipped by the caller, and on Windows the wait for an exiting leader ends at `deadline`.
1170    fn start_termination_within(
1171        &mut self,
1172        options: TerminationOptions,
1173        deadline: Option<Instant>,
1174    ) -> io::Result<()> {
1175        #[cfg(unix)]
1176        if !self.tree_terminated {
1177            self.signal(libc::SIGTERM)?;
1178            // Stops waiting as soon as nothing but the exited, unreaped leader is left (or the
1179            // leader was reaped elsewhere, which ends signalling). The leader is not reaped
1180            // here: it must keep pinning the group id for the KILL below.
1181            self.await_tree(options.grace)?;
1182        }
1183        self.force_kill_within(options.windows_exit_code, deadline)
1184    }
1185    /// Waits, for at most `grace`, until the leader has exited and every other member of its
1186    /// group has too. The leader is observed without reaping it; then the group is listed
1187    /// every [`POLL_INTERVAL`](crate::exit_watch::POLL_INTERVAL) (the published 0.3.3 interval)
1188    /// until nothing but the leader is left. A group that cannot be listed is waited out for
1189    /// the rest of the grace in one timed sleep. Each listing takes the child lock briefly and
1190    /// releases it before the thread sleeps. Bounded by `grace` in every case.
1191    #[cfg(unix)]
1192    fn await_tree(&self, grace: Duration) -> io::Result<()> {
1193        // A detached child owns only its direct pid.
1194        await_tree_shared(&self.child, &self.handle, grace, !self.detached)
1195    }
1196    /// Sends `signal` to the group (or, for a detached child, to the leader alone) while the
1197    /// leader is unreaped and proven. Takes the child lock for each attempt and releases it
1198    /// between attempts.
1199    #[cfg(unix)]
1200    fn signal(&self, signal: i32) -> io::Result<()> {
1201        // Detached children only own their direct pid. Under the child's lock, and only while
1202        // the leader is unreaped, so the id cannot have been recycled.
1203        let target = if self.detached {
1204            SignalTarget::Leader
1205        } else {
1206            SignalTarget::Group
1207        };
1208        signal_shared(&self.child, target, signal).map(|_| ())
1209    }
1210    fn force_kill(&mut self, code: u32) -> io::Result<()> {
1211        self.force_kill_within(code, None)
1212    }
1213    /// The kill. Windows: `deadline` ends the wait for a leader already exiting when
1214    /// `TerminateProcess` refuses it (at most the usual two seconds). Unix sends KILL and waits
1215    /// for nothing.
1216    fn force_kill_within(&mut self, code: u32, deadline: Option<Instant>) -> io::Result<()> {
1217        if self.tree_terminated {
1218            return Ok(());
1219        }
1220        #[cfg(unix)]
1221        {
1222            let _ = (code, deadline);
1223            self.signal(libc::SIGKILL)?;
1224        }
1225        #[cfg(windows)]
1226        crate::process_windows::terminate(&self.handle, self.job.as_deref(), code, deadline)?;
1227        self.tree_terminated = true;
1228        Ok(())
1229    }
1230}
1231impl OwnedChild {
1232    /// How long dropping this child waits for the killed leader to be reaped (default
1233    /// [`DEFAULT_DROP_REAP_TIMEOUT`]). `Drop` sends the kill at once and never waits longer than
1234    /// this: a leader that has not been reaped by then (it is in an uninterruptible state, for
1235    /// instance) is reaped later by a background thread on Unix, and on Windows the process
1236    /// handle is closed. `Duration::ZERO` means "do not wait". Does not affect
1237    /// [`terminate_tree`](Self::terminate_tree), which still waits for the reap.
1238    pub fn set_drop_reap_timeout(&mut self, timeout: Duration) -> &mut Self {
1239        self.drop_reap_timeout = timeout;
1240        self
1241    }
1242    /// The bound `Drop` applies to the reap.
1243    pub fn drop_reap_timeout(&self) -> Duration {
1244        self.drop_reap_timeout
1245    }
1246    /// Reaps the leader on a thread of its own, so an unbounded wait never blocks the caller.
1247    fn reap_in_background(&self) {
1248        #[cfg(unix)]
1249        if let Ok(reader) = self.handle.try_clone() {
1250            let _ = thread::Builder::new()
1251                .name("rightkit-drop-reap".into())
1252                .spawn(move || {
1253                    let _ = reader.wait();
1254                });
1255        }
1256    }
1257    /// The pids of everything this child left running besides the leader, read without
1258    /// signalling or reaping anything: the call a caller makes to record survivors before a kill
1259    /// removes the evidence.
1260    ///
1261    /// Windows: every member of the retained job (grandchildren included, even after the leader
1262    /// exited), plus parent-link descendants of the leader. Unix: members of the leader's process
1263    /// group (while the leader is unreaped) plus parent-link descendants (see
1264    /// [`descendant_identities`](Self::descendant_identities)). Order is unspecified, the leader
1265    /// is never listed, and the list is a snapshot: a member can exit as soon as it is read. A
1266    /// listing that fails is reported as the error, never as an empty tree.
1267    pub fn tree_members(&self) -> io::Result<Vec<u32>> {
1268        let leader = self.pid;
1269        let mut pids: Vec<u32> = Vec::new();
1270        #[cfg(windows)]
1271        if let Some(job) = self.job.as_ref() {
1272            pids.extend(job.process_ids()?);
1273        }
1274        #[cfg(unix)]
1275        if !self.detached {
1276            let group = match lock_child(&self.child).group_other_members()? {
1277                crate::unix_child::Members::Pids(group) => Some(group),
1278                crate::unix_child::Members::Unknown => None,
1279            };
1280            pids.extend(listed_or_error(group)?);
1281        }
1282        for identity in self.descendant_identities().unwrap_or_default() {
1283            pids.push(identity.pid());
1284        }
1285        pids.retain(|p| *p != leader);
1286        pids.sort_unstable();
1287        pids.dedup();
1288        Ok(pids)
1289    }
1290    /// Identities of this child's descendants (see [`crate::AdoptedIdentity::descendants`]), taken
1291    /// while the leader is proven to be this child. Empty once the leader has been reaped:
1292    /// its children are re-parented then and can no longer be found by their parent.
1293    pub fn descendant_identities(&self) -> io::Result<Vec<crate::AdoptedIdentity>> {
1294        let root = self.while_unreaped(|pid| match NonZeroU32::new(pid) {
1295            Some(pid) => crate::AdoptedIdentity::capture(pid),
1296            None => Ok(None),
1297        })?;
1298        match root {
1299            Some(Ok(Some(root))) => root.descendants(),
1300            Some(Err(error)) => Err(error),
1301            _ => Ok(Vec::new()),
1302        }
1303    }
1304}
1305/// A group listing that the host could not give (`None`: too large for one listing, or the
1306/// enumeration failed while the group exists) is an error, never an empty tree: a caller that
1307/// records survivors before a kill must not read "unknown" as "nobody".
1308#[cfg_attr(not(unix), allow(dead_code))]
1309fn listed_or_error(listing: Option<Vec<u32>>) -> io::Result<Vec<u32>> {
1310    listing.ok_or_else(|| {
1311        io::Error::other("the process group could not be fully listed (too large, or the host refused the enumeration)")
1312    })
1313}
1314impl Drop for OwnedChild {
1315    fn drop(&mut self) {
1316        // No longer owned: leave the registry before anything below can take long.
1317        crate::registry::forget(&self.child);
1318        if self.terminate_on_drop {
1319            // Bounded: the kill is sent at once, the reap is awaited for at most
1320            // `drop_reap_timeout`, and a child that has not been reaped by then is reaped by
1321            // a background thread. Dropping never waits on a process that will not die.
1322            if self.force_kill(1).is_ok() {
1323                drop(self.child().stdin.take());
1324                if !matches!(
1325                    self.handle.wait_timeout(self.drop_reap_timeout),
1326                    Ok(Some(_))
1327                ) {
1328                    self.reap_in_background();
1329                }
1330            }
1331        } else if self.detached {
1332            #[cfg(unix)]
1333            {
1334                // Shared launch still needs eventual reaping. Close owner pipes
1335                // now; the reaper thread makes non-blocking looks and releases the child
1336                // lock between them, so exit readers and kills are not held behind it.
1337                {
1338                    let mut child = self.child();
1339                    drop(child.stdin.take());
1340                    drop(child.stdout.take());
1341                    drop(child.stderr.take());
1342                }
1343                if let Ok(reader) = self.handle.try_clone() {
1344                    let _ = thread::Builder::new()
1345                        .name("rightkit-detached-reap".into())
1346                        .spawn(move || {
1347                            let _ = reader.wait();
1348                        });
1349                }
1350            }
1351        }
1352    }
1353}
1354
1355/// A process was created, but its wrapper could not be completed. PartialChild/direct
1356/// cleanup is best effort; its lack of a reap receipt must never become NotStarted.
1357pub(crate) fn spawn_setup_failure(error: io::Error) -> io::Error {
1358    crate::run::failure(
1359        crate::RunnerPhase::Setup,
1360        crate::LeaderTermination::Unconfirmed,
1361        error,
1362    )
1363}
1364
1365pub(crate) fn spawn_cleanup_failure(error: io::Error, reaped: bool) -> io::Error {
1366    if reaped {
1367        spawn_setup_failure(error)
1368    } else {
1369        crate::run::failure(
1370            crate::RunnerPhase::Cleanup,
1371            crate::LeaderTermination::Unconfirmed,
1372            error,
1373        )
1374    }
1375}
1376
1377struct PartialChild {
1378    child: Option<OwnedChild>,
1379    timeout: Option<Duration>,
1380}
1381impl PartialChild {
1382    fn cleanup(&mut self) -> bool {
1383        if let Some(mut child) = self.child.take() {
1384            child.terminate_on_drop = false;
1385            let _ = child.force_kill(1);
1386            cleanup_direct(Arc::clone(&child.child), self.timeout)
1387        } else {
1388            true
1389        }
1390    }
1391
1392    fn fail(mut self, error: io::Error) -> io::Error {
1393        let reaped = self.cleanup();
1394        spawn_cleanup_failure(error, reaped)
1395    }
1396}
1397impl Drop for PartialChild {
1398    fn drop(&mut self) {
1399        self.cleanup();
1400    }
1401}
1402/// How long a group signal that macOS refused with `EPERM` (the last members are mid-exit) is
1403/// retried before the refusal is reported.
1404#[cfg(unix)]
1405const MID_EXIT_RETRY: Duration = Duration::from_millis(300);
1406
1407/// One signal to a shared child's group or leader (see `OwnedChild::signal`), retrying for a
1408/// bounded time while macOS reports the last members mid-exit. The lock is taken for each
1409/// attempt and released between attempts. Returns the final outcome.
1410#[cfg(unix)]
1411pub(crate) fn signal_shared(
1412    shared: &Mutex<crate::unix_child::UnixChild>,
1413    target: SignalTarget,
1414    signal: i32,
1415) -> io::Result<SignalOutcome> {
1416    let started = Instant::now();
1417    loop {
1418        let (outcome, watch) = {
1419            let mut child = lock_child(shared);
1420            (child.signal(target, signal)?, child.exit_watch())
1421        };
1422        if outcome != SignalOutcome::MidExit {
1423            return Ok(outcome);
1424        }
1425        // macOS refused the group signal while its last members are mid-exit. The group is
1426        // gone once the exit completes: look again, for a bounded time, without the lock.
1427        let remaining = MID_EXIT_RETRY.saturating_sub(started.elapsed());
1428        if remaining.is_zero() {
1429            return Err(io::Error::from_raw_os_error(libc::EPERM));
1430        }
1431        watch.pause(remaining);
1432    }
1433}
1434
1435/// `OwnedChild::await_tree` over a shared child. `whole_group` false waits only for the leader.
1436#[cfg(unix)]
1437pub(crate) fn await_tree_shared(
1438    shared: &Mutex<crate::unix_child::UnixChild>,
1439    handle: &ProcessHandle,
1440    grace: Duration,
1441    whole_group: bool,
1442) -> io::Result<()> {
1443    use crate::{exit_watch::POLL_INTERVAL, unix_child::Members};
1444    let Some(deadline) = Instant::now().checked_add(grace) else {
1445        return Ok(());
1446    };
1447    let left = |deadline: Instant| deadline.saturating_duration_since(Instant::now());
1448    if !handle.wait_exited_timeout(left(deadline))? {
1449        return Ok(()); // the leader outlived the grace
1450    }
1451    if !whole_group {
1452        return Ok(());
1453    }
1454    loop {
1455        match lock_child(shared).group_other_members()? {
1456            Members::Pids(pids) if pids.is_empty() => return Ok(()),
1457            Members::Pids(_) => {}
1458            Members::Unknown => return wait_out(left(deadline)),
1459        }
1460        let remaining = left(deadline);
1461        if remaining.is_zero() {
1462            return Ok(()); // the grace is over
1463        }
1464        thread::sleep(POLL_INTERVAL.min(remaining));
1465    }
1466}
1467
1468/// The rest of a grace for a group that cannot be listed. There is nothing to look at, so this
1469/// is one timed sleep, not a loop.
1470#[cfg(unix)]
1471fn wait_out(remaining: Duration) -> io::Result<()> {
1472    thread::sleep(remaining);
1473    Ok(())
1474}
1475/// Stops and reaps a child whose spawn failed after the process existed. Every caller passes
1476/// an `Arc` that nothing but the spawning thread holds (the partial `OwnedChild`, from which no
1477/// `ProcessHandle` was cloned and which no caller has seen, or a child that was never wrapped),
1478/// so no other thread can contend for its lock. That is why the two blocking-under-lock waits
1479/// below, the unbounded one and the `rightkit-partial-reap` thread, cannot hold a deadline-
1480/// bearing caller back; both are what 0.3.3 did with `std::process::Child::wait`.
1481pub(crate) fn cleanup_direct(child: Arc<Mutex<PlatformChild>>, timeout: Option<Duration>) -> bool {
1482    let _ = lock_child(&child).kill();
1483    let Some(timeout) = timeout else {
1484        // Unbounded by default, as before. Exclusive child: see above.
1485        return lock_child(&child).wait().is_ok();
1486    };
1487    #[cfg(test)]
1488    let refused = cleanup_test_fault::refuse(timeout);
1489    #[cfg(not(test))]
1490    let refused = false;
1491    #[cfg(unix)]
1492    {
1493        // Non-blocking looks, each under the lock and released at once, until the child is
1494        // reaped or the timeout passes. A killed child is gone within one hint or interval.
1495        let watch = lock_child(&child).exit_watch();
1496        let deadline = Instant::now().checked_add(timeout);
1497        if !refused
1498            && matches!(
1499                watch.wait_for(deadline, || lock_child(&child).try_wait()),
1500                Ok(Some(_))
1501            )
1502        {
1503            return true;
1504        }
1505    }
1506    #[cfg(windows)]
1507    if !refused {
1508        let started = Instant::now();
1509        loop {
1510            match lock_child(&child).try_wait() {
1511                Ok(Some(_)) => return true,
1512                Err(_) => break,
1513                Ok(None) => {}
1514            }
1515            if started.elapsed() >= timeout {
1516                break;
1517            }
1518            thread::sleep(Duration::from_millis(5).min(timeout.saturating_sub(started.elapsed())));
1519        }
1520    }
1521    // std Child does not reap on Drop. Transfer timed-out children to a waiter. This thread
1522    // holds the child's lock for the whole wait and is the only holder of the `Arc` (see
1523    // above), so nothing can queue behind it.
1524    let _ = thread::Builder::new()
1525        .name("rightkit-partial-reap".into())
1526        .spawn(move || {
1527            let _ = lock_child(&child).wait();
1528        });
1529    false
1530}
1531
1532#[cfg(test)]
1533pub(crate) mod cleanup_test_fault {
1534    use std::{cell::Cell, time::Duration};
1535    thread_local! { static REFUSE_REAP: Cell<bool> = const { Cell::new(false) }; }
1536    thread_local! { static JOB_EMPTY_AT: Cell<Option<std::time::Instant>> = const { Cell::new(None) }; }
1537    thread_local! { static TERMINATION_REFUSED: Cell<bool> = const { Cell::new(false) }; }
1538    thread_local! { static HANDOFF_DELAY: Cell<Duration> = const { Cell::new(Duration::ZERO) }; }
1539    /// Test only: the next bounded reap on this thread pauses this long between the root's
1540    /// reap and the job-emptiness wait (models the thread being descheduled there).
1541    #[allow(dead_code)]
1542    pub(crate) fn arm_handoff_delay(delay: Duration) {
1543        HANDOFF_DELAY.with(|armed| armed.set(delay));
1544    }
1545    #[allow(dead_code)]
1546    pub(crate) fn handoff_delay() {
1547        let delay = HANDOFF_DELAY.with(|armed| armed.replace(Duration::ZERO));
1548        if !delay.is_zero() {
1549            std::thread::sleep(delay);
1550        }
1551    }
1552    /// Test only: on this thread, retained-job emptiness queries report one active process
1553    /// until `at` and none from then on (models a descendant that exits at a chosen moment).
1554    #[allow(dead_code)]
1555    pub(crate) fn arm_job_empty_at(at: std::time::Instant) {
1556        JOB_EMPTY_AT.with(|armed| armed.set(Some(at)));
1557    }
1558    #[allow(dead_code)]
1559    pub(crate) fn disarm_job_empty_at() {
1560        JOB_EMPTY_AT.with(|armed| armed.set(None));
1561    }
1562    /// The armed active-process count, or `None` to query the job.
1563    #[allow(dead_code)]
1564    pub(crate) fn job_count() -> Option<u32> {
1565        JOB_EMPTY_AT
1566            .with(|armed| armed.get())
1567            .map(|at| u32::from(std::time::Instant::now() < at))
1568    }
1569    /// Test only: the next Windows termination on this thread neither terminates the job nor
1570    /// the leader, and reports `TerminateProcess` as refused (models a leader that is exiting
1571    /// slowly), so the exiting-leader wait runs in full.
1572    #[allow(dead_code)]
1573    pub(crate) fn arm_termination_refused() {
1574        TERMINATION_REFUSED.with(|armed| armed.set(true));
1575    }
1576    #[allow(dead_code)]
1577    pub(crate) fn termination_refused() -> bool {
1578        TERMINATION_REFUSED.with(|armed| armed.replace(false))
1579    }
1580    pub(crate) fn arm() {
1581        REFUSE_REAP.with(|armed| armed.set(true));
1582    }
1583    pub(crate) fn refuse(timeout: Duration) -> bool {
1584        if REFUSE_REAP.with(|armed| armed.replace(false)) {
1585            // Model an observation that never acknowledges shutdown. Real kill still runs;
1586            // eventual native reaping remains owned by Drop or the partial-spawn reaper.
1587            std::thread::sleep(timeout);
1588            true
1589        } else {
1590            false
1591        }
1592    }
1593}
1594
1595#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1596pub struct AdoptedProcess {
1597    pid: NonZeroU32,
1598}
1599impl AdoptedProcess {
1600    pub fn new(pid: NonZeroU32) -> Self {
1601        Self { pid }
1602    }
1603    pub fn id(&self) -> u32 {
1604        self.pid.get()
1605    }
1606    /// Whether a process with this pid is running. Unix: a zombie (exited, unreaped by its
1607    /// own parent) reads `false`. This is a bare-pid probe with no start-time check; use
1608    /// [`identity`](Self::identity) when the pid must still name a remembered process.
1609    pub fn is_running(&self) -> io::Result<bool> {
1610        process_is_running(self.pid)
1611    }
1612    /// The identity (start time and executable) of the process behind this pid now, for
1613    /// identity-checked signalling through [`AdoptedIdentity`](crate::AdoptedIdentity).
1614    /// `Ok(None)` when no process has the pid. Capture it only when the pid is known to name
1615    /// the intended process.
1616    pub fn identity(&self) -> io::Result<Option<crate::AdoptedIdentity>> {
1617        crate::AdoptedIdentity::capture(self.pid)
1618    }
1619}
1620#[cfg(test)]
1621fn spawn_for_test(
1622    command: Command,
1623    captured_pid: Arc<std::sync::atomic::AtomicU32>,
1624) -> io::Result<OwnedChild> {
1625    OwnedCommand::from_command(command).spawn_inner(false, true, Some(captured_pid))
1626}
1627
1628#[cfg(windows)]
1629fn process_is_running(pid: NonZeroU32) -> io::Result<bool> {
1630    use windows::Win32::{
1631        Foundation::{CloseHandle, ERROR_INVALID_PARAMETER, WAIT_TIMEOUT},
1632        System::Threading::{
1633            OpenProcess, WaitForSingleObject, PROCESS_QUERY_LIMITED_INFORMATION,
1634            PROCESS_SYNCHRONIZE,
1635        },
1636    };
1637
1638    let handle = unsafe {
1639        OpenProcess(
1640            PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE,
1641            false,
1642            pid.get(),
1643        )
1644    };
1645    let handle = match handle {
1646        Ok(handle) => handle,
1647        Err(error) => {
1648            if error.code() == ERROR_INVALID_PARAMETER.to_hresult() {
1649                return Ok(false);
1650            }
1651            return Err(io::Error::other(error.to_string()));
1652        }
1653    };
1654    let wait = unsafe { WaitForSingleObject(handle, 0) };
1655    let close = unsafe { CloseHandle(handle) };
1656    close.map_err(|error| io::Error::other(error.to_string()))?;
1657    match wait.0 {
1658        0 => Ok(false),
1659        value if value == WAIT_TIMEOUT.0 => Ok(true),
1660        _ => Err(io::Error::last_os_error()),
1661    }
1662}
1663
1664#[cfg(unix)]
1665fn process_is_running(pid: NonZeroU32) -> io::Result<bool> {
1666    let raw_pid = i32::try_from(pid.get()).map_err(io::Error::other)?;
1667    loop {
1668        // SAFETY: signal 0 performs only existence and permission checks; nothing is sent.
1669        if unsafe { libc::kill(raw_pid, 0) } == 0 {
1670            // Existence includes zombies: an exited process nobody reaped is dead, not
1671            // running (and is never reaped here - that is its parent's job).
1672            return Ok(!crate::foreign::process_is_zombie(pid.get()));
1673        }
1674        let error = io::Error::last_os_error();
1675        match error.raw_os_error() {
1676            Some(libc::EINTR) => continue,
1677            Some(libc::EPERM) => return Ok(!crate::foreign::process_is_zombie(pid.get())),
1678            Some(libc::ESRCH) => return Ok(false),
1679            _ => return Err(error),
1680        }
1681    }
1682}
1683
1684#[cfg(test)]
1685mod tests {
1686    #[test]
1687    fn option_defaults_preserve_existing_behavior() {
1688        let command = super::OwnedCommand::new("unused");
1689        assert_eq!(command.job_assignment, super::JobAssignmentMode::Strict);
1690        assert_eq!(
1691            command.unix_containment,
1692            super::UnixContainment::ProcessGroup
1693        );
1694        assert_eq!(command.partial_spawn_cleanup_timeout, None);
1695        assert_eq!(
1696            super::TerminationOptions::default().grace,
1697            std::time::Duration::ZERO
1698        );
1699        assert_eq!(super::TerminationOptions::default().windows_exit_code, 1);
1700    }
1701
1702    #[cfg(not(windows))]
1703    #[test]
1704    fn new_and_from_command_share_spawn_settings() {
1705        use super::OwnedCommand;
1706        use std::process::Command;
1707        // Debug covers every field. Off Windows nothing differs between the constructors.
1708        let by_new = OwnedCommand::new("unused");
1709        let by_from = OwnedCommand::from_command(Command::new("unused"));
1710        assert_eq!(format!("{by_new:?}"), format!("{by_from:?}"));
1711    }
1712
1713    /// Only `new` knows its Command's stdio and environment are std defaults. A wrapped
1714    /// Command may carry settings the crate cannot read, so `from_command` must not invent a
1715    /// default config: the allowlisted spawn then refuses instead of widening inheritance.
1716    #[cfg(windows)]
1717    #[test]
1718    fn only_new_supplies_a_default_windows_spawn_config() {
1719        use super::OwnedCommand;
1720        use std::process::Command;
1721        let by_new = OwnedCommand::new("unused");
1722        let by_from = OwnedCommand::from_command(Command::new("unused"));
1723        assert!(by_new.windows_spawn_config.is_some() && by_new.windows_spawn_config_auto);
1724        assert!(by_from.windows_spawn_config.is_none() && !by_from.windows_spawn_config_auto);
1725    }
1726
1727    #[test]
1728    fn process_priority_defaults_to_normal_and_keeps_the_requested_value() {
1729        use super::{OwnedCommand, ProcessPriority};
1730        let mut command = OwnedCommand::new("unused");
1731        assert_eq!(command.process_priority, ProcessPriority::Normal);
1732        command.process_priority(ProcessPriority::BelowNormal);
1733        assert_eq!(command.process_priority, ProcessPriority::BelowNormal);
1734        command.process_priority(ProcessPriority::Idle);
1735        assert_eq!(command.process_priority, ProcessPriority::Idle);
1736    }
1737
1738    /// Normal adds no nice change, so the default Unix spawn is unchanged. Lower priorities
1739    /// map to the documented floors.
1740    #[cfg(unix)]
1741    #[test]
1742    fn process_priority_maps_to_nice_floors() {
1743        use super::ProcessPriority;
1744        assert_eq!(ProcessPriority::Normal.nice_floor(), None);
1745        assert_eq!(ProcessPriority::BelowNormal.nice_floor(), Some(10));
1746        assert_eq!(ProcessPriority::Idle.nice_floor(), Some(19));
1747        assert_eq!(ProcessPriority::Efficient.nice_floor(), Some(10));
1748    }
1749
1750    /// A real child at `BelowNormal` reports the class Windows was asked for.
1751    #[cfg(windows)]
1752    #[test]
1753    fn below_normal_child_has_below_normal_priority_class() {
1754        use super::{OwnedCommand, ProcessPriority};
1755        use std::process::Stdio;
1756        use windows::Win32::{
1757            Foundation::CloseHandle,
1758            System::Threading::{
1759                GetPriorityClass, OpenProcess, BELOW_NORMAL_PRIORITY_CLASS,
1760                PROCESS_QUERY_LIMITED_INFORMATION,
1761            },
1762        };
1763        let mut command = OwnedCommand::new("ping");
1764        command
1765            .command_mut()
1766            .args(["-n", "6", "127.0.0.1"])
1767            .stdin(Stdio::null())
1768            .stdout(Stdio::null())
1769            .stderr(Stdio::null());
1770        command.process_priority(ProcessPriority::BelowNormal);
1771        let mut child = command.spawn().expect("spawn ping");
1772        // SAFETY: the handle is opened for this live child, queried once and closed.
1773        let class = unsafe {
1774            let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, child.id())
1775                .expect("open the child");
1776            let class = GetPriorityClass(handle);
1777            let _ = CloseHandle(handle);
1778            class
1779        };
1780        let _ = child.terminate_tree();
1781        assert_eq!(class, BELOW_NORMAL_PRIORITY_CLASS.0);
1782    }
1783
1784    /// A real child at `Idle` or `BelowNormal` runs with a nice value at or above the floor.
1785    #[cfg(unix)]
1786    #[test]
1787    fn lowered_priority_child_runs_at_or_above_the_nice_floor() {
1788        use super::{OwnedCommand, ProcessPriority};
1789        use std::{io::Read, process::Stdio};
1790        for (priority, floor) in [
1791            (ProcessPriority::BelowNormal, 10),
1792            (ProcessPriority::Idle, 19),
1793        ] {
1794            let mut command = OwnedCommand::new("/bin/sh");
1795            command
1796                .command_mut()
1797                .args(["-c", "ps -o ni= -p $$"])
1798                .stdin(Stdio::null())
1799                .stdout(Stdio::piped())
1800                .stderr(Stdio::null());
1801            command.process_priority(priority);
1802            let mut child = command.spawn().expect("spawn sh");
1803            let mut text = String::new();
1804            child
1805                .take_stdout()
1806                .expect("piped stdout")
1807                .read_to_string(&mut text)
1808                .expect("read ps output");
1809            let _ = child.wait();
1810            let nice: i32 = text.trim().parse().expect("ps printed a nice value");
1811            assert!(nice >= floor, "nice {nice} below floor {floor}");
1812        }
1813    }
1814
1815    #[test]
1816    fn breakaway_is_off_by_default_explicit_by_default_opt_in_and_silent_is_separate() {
1817        use super::BreakawayMode;
1818        let mut command = super::OwnedCommand::new("unused");
1819        assert_eq!(command.windows_breakaway, BreakawayMode::None);
1820        command.windows_allow_breakaway(true);
1821        assert_eq!(command.windows_breakaway, BreakawayMode::Explicit);
1822        command.windows_allow_silent_breakaway(true);
1823        assert_eq!(command.windows_breakaway, BreakawayMode::Silent);
1824        command.windows_allow_silent_breakaway(false);
1825        assert_eq!(command.windows_breakaway, BreakawayMode::None);
1826    }
1827
1828    #[test]
1829    fn kill_on_parent_exit_is_opt_in() {
1830        let mut command = super::OwnedCommand::new("unused");
1831        assert!(!command.kill_on_parent_exit);
1832        command.kill_on_parent_exit(true);
1833        assert!(command.kill_on_parent_exit);
1834    }
1835
1836    #[test]
1837    fn breakaway_denied_is_a_typed_recoverable_error() {
1838        let error =
1839            std::io::Error::new(std::io::ErrorKind::PermissionDenied, super::BreakawayDenied);
1840        assert!(error
1841            .get_ref()
1842            .and_then(|source| source.downcast_ref::<super::BreakawayDenied>())
1843            .is_some());
1844        assert!(super::BreakawayDenied.to_string().contains("breakaway"));
1845    }
1846
1847    #[test]
1848    fn a_strict_detached_spawn_passes_other_failures_through_untyped() {
1849        let command = super::OwnedCommand::new("rightkit-no-such-program-for-strict-detach");
1850        let Err(error) = command.spawn_uncontained_detached_strict() else {
1851            panic!("a missing program must not spawn");
1852        };
1853        assert!(error
1854            .get_ref()
1855            .and_then(|source| source.downcast_ref::<super::BreakawayDenied>())
1856            .is_none());
1857    }
1858
1859    #[test]
1860    fn a_strict_detached_spawn_still_rejects_kill_on_parent_exit() {
1861        let mut command = super::OwnedCommand::new("unused");
1862        command.kill_on_parent_exit(true);
1863        let Err(error) = command.spawn_uncontained_detached_strict() else {
1864            panic!("a detached spawn must not accept the parent-death tie");
1865        };
1866        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
1867    }
1868
1869    #[test]
1870    fn a_detached_spawn_rejects_kill_on_parent_exit() {
1871        // Rejected before anything is started, so the program never needs to exist.
1872        let mut command = super::OwnedCommand::new("unused");
1873        command.kill_on_parent_exit(true);
1874        let Err(error) = command.spawn_uncontained_detached() else {
1875            panic!("a detached spawn must not accept the parent-death tie");
1876        };
1877        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
1878    }
1879
1880    /// Hosts other than Linux and macOS have no parent-death primitive: the option fails closed,
1881    /// and the program is never started (it would create the marker file).
1882    #[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
1883    #[test]
1884    fn a_host_without_a_parent_death_primitive_refuses_before_starting_anything() {
1885        let marker =
1886            std::env::temp_dir().join(format!("rightkit-process-untied-{}", std::process::id()));
1887        let _ = std::fs::remove_file(&marker);
1888        let mut command = super::OwnedCommand::new("/usr/bin/touch");
1889        command.command_mut().arg(&marker);
1890        command.kill_on_parent_exit(true);
1891        let Err(error) = command.spawn() else {
1892            panic!("kill_on_parent_exit must not run the child untied");
1893        };
1894        assert_eq!(error.kind(), std::io::ErrorKind::Unsupported);
1895        std::thread::sleep(std::time::Duration::from_millis(200));
1896        assert!(!marker.exists(), "the program ran");
1897    }
1898
1899    #[test]
1900    fn option_builders_keep_every_requested_value() {
1901        use super::{JobAssignmentMode, OwnedCommand, TerminationOptions, UnixContainment};
1902        use std::time::Duration;
1903        let mut command = OwnedCommand::new("unused");
1904        command
1905            .job_assignment_mode(JobAssignmentMode::BestEffort)
1906            .unix_containment(UnixContainment::Session)
1907            .partial_spawn_cleanup_timeout(Duration::from_secs(5));
1908        assert_eq!(command.job_assignment, JobAssignmentMode::BestEffort);
1909        assert_eq!(command.unix_containment, UnixContainment::Session);
1910        assert_eq!(
1911            command.partial_spawn_cleanup_timeout,
1912            Some(Duration::from_secs(5))
1913        );
1914        let options = TerminationOptions::default()
1915            .with_grace(Duration::from_millis(100))
1916            .with_windows_exit_code(1067);
1917        assert_eq!(options.grace, Duration::from_millis(100));
1918        assert_eq!(options.windows_exit_code, 1067);
1919    }
1920
1921    #[test]
1922    fn zero_bound_partial_spawn_failure_still_reaps_the_child() {
1923        use std::{
1924            num::NonZeroU32,
1925            process::Command,
1926            sync::{
1927                atomic::{AtomicU32, Ordering},
1928                Arc,
1929            },
1930            thread,
1931            time::{Duration, Instant},
1932        };
1933        let command = if cfg!(windows) {
1934            let mut command = Command::new("powershell.exe");
1935            command.args([
1936                "-NoLogo",
1937                "-NoProfile",
1938                "-NonInteractive",
1939                "-Command",
1940                "Start-Sleep -Seconds 30",
1941            ]);
1942            command
1943        } else {
1944            let mut command = Command::new("/bin/sh");
1945            command.args(["-c", "exec sleep 30"]);
1946            command
1947        };
1948        let mut command = super::OwnedCommand::from_command(command);
1949        command.partial_spawn_cleanup_timeout(Duration::ZERO);
1950        let captured = Arc::new(AtomicU32::new(0));
1951        let started = Instant::now();
1952        super::cleanup_test_fault::arm();
1953        let error = command
1954            .spawn_inner(false, true, Some(Arc::clone(&captured)))
1955            .err()
1956            .unwrap();
1957        let detail = crate::RunnerFailure::from_io(&error).unwrap();
1958        assert_eq!(detail.phase(), crate::RunnerPhase::Cleanup);
1959        assert_eq!(detail.termination(), crate::LeaderTermination::Unconfirmed);
1960        assert!(started.elapsed() < Duration::from_secs(3));
1961        let pid = captured.load(Ordering::SeqCst);
1962        assert_ne!(pid, 0);
1963        let process = super::AdoptedProcess::new(NonZeroU32::new(pid).unwrap());
1964        let started = Instant::now();
1965        while process.is_running().unwrap() {
1966            assert!(started.elapsed() < Duration::from_secs(5));
1967            thread::sleep(Duration::from_millis(5));
1968        }
1969    }
1970
1971    #[test]
1972    fn owned_commands_hide_their_console_window_by_default() {
1973        assert!(super::OwnedCommand::new("x").windows_hidden);
1974        assert!(super::OwnedCommand::from_command(std::process::Command::new("x")).windows_hidden);
1975    }
1976
1977    /// A kill-on-close job standing in for an in-process host's own job object.
1978    #[cfg(windows)]
1979    fn host_job() -> std::os::windows::io::OwnedHandle {
1980        use std::os::windows::io::FromRawHandle;
1981        use windows::Win32::System::JobObjects::{
1982            CreateJobObjectW, JobObjectExtendedLimitInformation, SetInformationJobObject,
1983            JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
1984        };
1985        let job = unsafe { CreateJobObjectW(None, None) }.unwrap();
1986        let job_handle = unsafe { std::os::windows::io::OwnedHandle::from_raw_handle(job.0 as _) };
1987        let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
1988        info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
1989        unsafe {
1990            SetInformationJobObject(
1991                job,
1992                JobObjectExtendedLimitInformation,
1993                &info as *const _ as *const _,
1994                std::mem::size_of_val(&info) as u32,
1995            )
1996        }
1997        .unwrap();
1998        job_handle
1999    }
2000
2001    #[cfg(windows)]
2002    fn in_job(pid: u32, job: &std::os::windows::io::OwnedHandle) -> bool {
2003        use std::os::windows::io::AsRawHandle;
2004        #[link(name = "kernel32")]
2005        unsafe extern "system" {
2006            fn OpenProcess(access: u32, inherit: i32, pid: u32) -> *mut std::ffi::c_void;
2007            fn IsProcessInJob(
2008                process: *mut std::ffi::c_void,
2009                job: *mut std::ffi::c_void,
2010                result: *mut i32,
2011            ) -> i32;
2012            fn CloseHandle(handle: *mut std::ffi::c_void) -> i32;
2013        }
2014        unsafe {
2015            let process = OpenProcess(0x1000, 0, pid); // PROCESS_QUERY_LIMITED_INFORMATION
2016            assert!(!process.is_null());
2017            let mut inside = 0;
2018            assert_ne!(
2019                IsProcessInJob(process, job.as_raw_handle() as _, &mut inside),
2020                0
2021            );
2022            CloseHandle(process);
2023            inside != 0
2024        }
2025    }
2026
2027    #[cfg(windows)]
2028    #[test]
2029    fn children_join_the_callers_job_and_die_when_the_caller_closes_it() {
2030        use std::os::windows::io::AsHandle;
2031        use std::time::{Duration, Instant};
2032        let job = host_job();
2033        let sleeper = || {
2034            let mut command = super::OwnedCommand::new("powershell.exe");
2035            command.command_mut().args([
2036                "-NoLogo",
2037                "-NoProfile",
2038                "-NonInteractive",
2039                "-Command",
2040                "Start-Sleep -Seconds 60",
2041            ]);
2042            command.windows_job(job.as_handle()).unwrap();
2043            command.spawn().unwrap()
2044        };
2045        // The second child joins a job that already holds a process: binding must happen
2046        // before RightKit's own jobs, or Windows refuses it.
2047        let (first, second) = (sleeper(), sleeper());
2048        let pids = [first.id(), second.id()];
2049        assert!(
2050            pids.iter().all(|pid| in_job(*pid, &job)),
2051            "children must be in the caller's job"
2052        );
2053
2054        // The caller closes its job while the owned children are still alive: they die.
2055        drop(job);
2056        let deadline = Instant::now() + Duration::from_secs(5);
2057        for pid in pids {
2058            let process = super::AdoptedProcess::new(std::num::NonZeroU32::new(pid).unwrap());
2059            while process.is_running().unwrap_or(false) {
2060                assert!(
2061                    Instant::now() < deadline,
2062                    "child {pid} survived its caller's job"
2063                );
2064                std::thread::sleep(Duration::from_millis(20));
2065            }
2066        }
2067        drop((first, second));
2068    }
2069
2070    #[test]
2071    fn a_failure_after_spawn_cleans_up_the_partially_wrapped_child() {
2072        use std::{
2073            process::Command,
2074            sync::{
2075                atomic::{AtomicU32, Ordering},
2076                Arc,
2077            },
2078            thread,
2079            time::{Duration, Instant},
2080        };
2081
2082        // A long-lived child that survives on its own if the partial-spawn cleanup fails.
2083        let command = if cfg!(windows) {
2084            // windowless: spawned through spawn_owned (CREATE_NO_WINDOW).
2085            let mut command = Command::new("powershell.exe");
2086            command.args([
2087                "-NoLogo",
2088                "-NoProfile",
2089                "-NonInteractive",
2090                "-Command",
2091                "Start-Sleep -Seconds 60",
2092            ]);
2093            command
2094        } else {
2095            // windowless: spawned through spawn_owned (CREATE_NO_WINDOW).
2096            let mut command = Command::new("sleep");
2097            command.arg("60");
2098            command
2099        };
2100        let spawned_pid = Arc::new(AtomicU32::new(0));
2101
2102        let error = match super::spawn_for_test(command, Arc::clone(&spawned_pid)) {
2103            Ok(_) => panic!("injected wrapping failure must fail after spawning"),
2104            Err(error) => error,
2105        };
2106        let detail = crate::RunnerFailure::from_io(&error).expect("partial-spawn provenance");
2107        assert_eq!(detail.phase(), crate::RunnerPhase::Setup);
2108        assert_eq!(detail.termination(), crate::LeaderTermination::Unconfirmed);
2109        let pid = spawned_pid.load(Ordering::SeqCst);
2110        assert_ne!(pid, 0, "test must fail after the OS process was spawned");
2111        let process = super::AdoptedProcess::new(std::num::NonZeroU32::new(pid).unwrap());
2112        let deadline = Instant::now() + Duration::from_secs(5);
2113        while process.is_running().unwrap_or(false) {
2114            assert!(
2115                Instant::now() < deadline,
2116                "partially spawned process leaked"
2117            );
2118            thread::sleep(Duration::from_millis(20));
2119        }
2120    }
2121
2122    /// An owned tree whose root starts a grandchild (left in the root's private job) and
2123    /// records its pid; with `wait` the root then waits on it, else the root exits at once.
2124    #[cfg(windows)]
2125    fn tree_with_grandchild(pid_file: &std::path::Path, wait: bool) -> (super::OwnedChild, u32) {
2126        use std::time::{Duration, Instant};
2127        let _ = std::fs::remove_file(pid_file);
2128        let quoted = pid_file.display().to_string().replace('\'', "''");
2129        let tail = if wait {
2130            "; Wait-Process -Id $child.Id"
2131        } else {
2132            ""
2133        };
2134        let mut command = std::process::Command::new("powershell.exe");
2135        command
2136            .args([
2137                "-NoLogo",
2138                "-NoProfile",
2139                "-NonInteractive",
2140                "-Command",
2141                &format!(
2142                    "$child = Start-Process powershell.exe -WindowStyle Hidden -ArgumentList '-NoLogo','-NoProfile','-NonInteractive','-Command','Start-Sleep -Seconds 60' -PassThru; Set-Content -LiteralPath '{quoted}' -Value $child.Id{tail}"
2143                ),
2144            ])
2145            .stdin(std::process::Stdio::null())
2146            .stdout(std::process::Stdio::null())
2147            .stderr(std::process::Stdio::null());
2148        let mut command = super::OwnedCommand::from_command(command);
2149        command.windows_hide();
2150        let owned = command.spawn().unwrap();
2151        let deadline = Instant::now() + Duration::from_secs(15);
2152        let grandchild = loop {
2153            if let Some(pid) = std::fs::read_to_string(pid_file)
2154                .ok()
2155                .and_then(|text| text.trim().parse().ok())
2156            {
2157                break pid;
2158            }
2159            assert!(Instant::now() < deadline, "grandchild pid was not recorded");
2160            std::thread::sleep(Duration::from_millis(20));
2161        };
2162        (owned, grandchild)
2163    }
2164
2165    #[cfg(windows)]
2166    fn job_active(owned: &super::OwnedChild) -> u32 {
2167        let job = owned
2168            .job
2169            .as_ref()
2170            .expect("an owned child keeps its private job");
2171        crate::windows_job::WindowsJob::active_count_of(crate::process_windows::raw_job(job))
2172            .unwrap()
2173    }
2174
2175    /// rightkit#1260: `Exited` means the whole private job is empty, not only that the root
2176    /// exited. Read with no wait after the call returns.
2177    #[cfg(windows)]
2178    #[test]
2179    fn bounded_reap_reports_exited_only_once_the_job_is_empty() {
2180        use std::time::Duration;
2181        let pid_file =
2182            std::env::temp_dir().join(format!("rightkit-1260-live-{}.pid", std::process::id()));
2183        let (mut owned, grandchild) = tree_with_grandchild(&pid_file, true);
2184        let grandchild = super::AdoptedProcess::new(std::num::NonZeroU32::new(grandchild).unwrap());
2185        assert!(grandchild.is_running().unwrap());
2186        assert!(
2187            job_active(&owned) >= 2,
2188            "root and grandchild are in the job"
2189        );
2190
2191        let outcome = owned
2192            .terminate_tree_bounded(Duration::from_secs(10))
2193            .unwrap();
2194        assert!(
2195            matches!(outcome, super::ReapOutcome::Exited(_)),
2196            "{outcome:?}"
2197        );
2198        assert_eq!(
2199            job_active(&owned),
2200            0,
2201            "Exited while the job still held a process"
2202        );
2203        assert!(
2204            !grandchild.is_running().unwrap_or(false),
2205            "grandchild outlived Exited"
2206        );
2207        let _ = std::fs::remove_file(&pid_file);
2208    }
2209
2210    /// rightkit#1260: a root that already exited (and was reaped) still has its descendants
2211    /// swept, and `Exited` still waits for the job to empty.
2212    #[cfg(windows)]
2213    #[test]
2214    fn bounded_reap_sweeps_descendants_of_an_already_exited_root() {
2215        use std::time::Duration;
2216        let pid_file =
2217            std::env::temp_dir().join(format!("rightkit-1260-orphan-{}.pid", std::process::id()));
2218        let (mut owned, grandchild) = tree_with_grandchild(&pid_file, false);
2219        let grandchild = super::AdoptedProcess::new(std::num::NonZeroU32::new(grandchild).unwrap());
2220        assert!(
2221            owned
2222                .wait_timeout(Duration::from_secs(15))
2223                .unwrap()
2224                .is_some(),
2225            "root must exit on its own"
2226        );
2227        assert!(
2228            grandchild.is_running().unwrap(),
2229            "grandchild outlives its root"
2230        );
2231        assert!(
2232            job_active(&owned) >= 1,
2233            "grandchild stays in the root's job"
2234        );
2235
2236        let outcome = owned
2237            .terminate_tree_bounded(Duration::from_secs(10))
2238            .unwrap();
2239        assert!(
2240            matches!(outcome, super::ReapOutcome::Exited(_)),
2241            "{outcome:?}"
2242        );
2243        assert_eq!(job_active(&owned), 0);
2244        assert!(
2245            !grandchild.is_running().unwrap_or(false),
2246            "descendant was not swept"
2247        );
2248        let _ = std::fs::remove_file(&pid_file);
2249    }
2250
2251    /// rightkit#1260: a job that is not seen empty within the budget is unconfirmed, even
2252    /// though the root itself exited, and the call returns at the budget, not later.
2253    #[cfg(windows)]
2254    #[test]
2255    fn bounded_reap_is_unconfirmed_while_the_job_is_not_empty() {
2256        use std::time::{Duration, Instant};
2257        let pid_file =
2258            std::env::temp_dir().join(format!("rightkit-1260-timeout-{}.pid", std::process::id()));
2259        let (mut owned, _) = tree_with_grandchild(&pid_file, true);
2260        let budget = Duration::from_millis(500);
2261        super::cleanup_test_fault::arm_job_empty_at(Instant::now() + Duration::from_secs(3600));
2262        let started = Instant::now();
2263        let outcome = owned.terminate_tree_bounded(budget);
2264        let elapsed = started.elapsed();
2265        super::cleanup_test_fault::disarm_job_empty_at();
2266        assert!(
2267            matches!(outcome, Ok(super::ReapOutcome::TimedOut)),
2268            "{outcome:?}"
2269        );
2270        assert!(
2271            elapsed < budget + Duration::from_millis(250),
2272            "budget overrun: {elapsed:?}"
2273        );
2274        let _ = std::fs::remove_file(&pid_file);
2275    }
2276
2277    /// rightkit#1260 review: an emptiness observed only after the deadline (the last poll's
2278    /// sleep overran it) is not success.
2279    #[cfg(windows)]
2280    #[test]
2281    fn job_seen_empty_only_after_the_deadline_is_not_success() {
2282        use std::time::{Duration, Instant};
2283        let pid_file =
2284            std::env::temp_dir().join(format!("rightkit-1260-late-{}.pid", std::process::id()));
2285        let (owned, _) = tree_with_grandchild(&pid_file, true);
2286        let job = owned.job.clone().expect("private job");
2287        let timeout = Duration::from_millis(100);
2288        // Empty only from 5 ms after the wait's own deadline.
2289        super::cleanup_test_fault::arm_job_empty_at(
2290            Instant::now() + timeout + Duration::from_millis(5),
2291        );
2292        let late = job.wait_empty_until(Some(Instant::now() + timeout));
2293        // Control: empty well inside the budget is success.
2294        super::cleanup_test_fault::arm_job_empty_at(Instant::now() + Duration::from_millis(20));
2295        let early = job.wait_empty_until(Some(Instant::now() + Duration::from_millis(500)));
2296        super::cleanup_test_fault::disarm_job_empty_at();
2297        assert!(
2298            !late.unwrap(),
2299            "a job seen empty after the deadline was reported empty"
2300        );
2301        assert!(
2302            early.unwrap(),
2303            "control: a job empty inside the budget is reported empty"
2304        );
2305        drop(owned);
2306        let _ = std::fs::remove_file(&pid_file);
2307    }
2308
2309    /// rightkit#1260 second review: a pause between the root's reap and the job wait never buys
2310    /// a fresh budget: a job that empties only after the original deadline is not `Exited`.
2311    #[cfg(windows)]
2312    #[test]
2313    fn bounded_reap_after_a_delayed_handoff_keeps_the_original_deadline() {
2314        use std::time::{Duration, Instant};
2315        let pid_file =
2316            std::env::temp_dir().join(format!("rightkit-1260-handoff-{}.pid", std::process::id()));
2317        let (mut owned, _) = tree_with_grandchild(&pid_file, true);
2318        let budget = Duration::from_millis(300);
2319        let started = Instant::now();
2320        // The thread stalls past the deadline before it asks the job; the job empties 20 ms
2321        // after the deadline, well inside any fresh budget the stall could have handed out.
2322        super::cleanup_test_fault::arm_job_empty_at(started + budget + Duration::from_millis(20));
2323        super::cleanup_test_fault::arm_handoff_delay(budget + Duration::from_millis(50));
2324        let outcome = owned.terminate_tree_bounded(budget);
2325        super::cleanup_test_fault::disarm_job_empty_at();
2326        assert!(
2327            matches!(outcome, Ok(super::ReapOutcome::TimedOut)),
2328            "a job empty only after the original deadline was confirmed: {outcome:?}"
2329        );
2330        drop(owned);
2331        let _ = std::fs::remove_file(&pid_file);
2332    }
2333
2334    /// rightkit#1260 review: the bounded call's kill step never waits its fixed two seconds for
2335    /// an exiting leader when less budget is left.
2336    #[cfg(windows)]
2337    #[test]
2338    fn bounded_reap_never_waits_past_its_budget_for_an_exiting_leader() {
2339        use std::time::{Duration, Instant};
2340        let pid_file =
2341            std::env::temp_dir().join(format!("rightkit-1260-refused-{}.pid", std::process::id()));
2342        let (mut owned, _) = tree_with_grandchild(&pid_file, true);
2343        let budget = Duration::from_millis(300);
2344        super::cleanup_test_fault::arm_termination_refused();
2345        let started = Instant::now();
2346        let outcome = owned.terminate_tree_bounded(budget);
2347        let elapsed = started.elapsed();
2348        assert!(
2349            !matches!(outcome, Ok(super::ReapOutcome::Exited(_))),
2350            "a refused termination was confirmed: {outcome:?}"
2351        );
2352        assert!(
2353            elapsed < budget + Duration::from_millis(250),
2354            "the kill step outlasted the budget: {elapsed:?}"
2355        );
2356        drop(owned);
2357        let _ = std::fs::remove_file(&pid_file);
2358    }
2359}
2360
2361#[cfg(test)]
2362mod tree_listing_tests {
2363    use super::listed_or_error;
2364
2365    #[test]
2366    fn an_unknown_group_listing_is_an_error_not_an_empty_tree() {
2367        let error = listed_or_error(None).expect_err("unknown must not read as empty");
2368        assert!(
2369            error.to_string().contains("could not be fully listed"),
2370            "{error}"
2371        );
2372    }
2373
2374    #[test]
2375    fn a_known_listing_passes_through_including_an_empty_one() {
2376        assert_eq!(listed_or_error(Some(vec![7, 9])).unwrap(), vec![7, 9]);
2377        assert!(listed_or_error(Some(Vec::new())).unwrap().is_empty());
2378    }
2379}