Skip to main content

running_process/
spawn.rs

1//! Two-mode process spawning. Free functions only — no module-internal traits.
2//!
3//! Modes (only two; the dangerous combination `detached + caller-pipes` has no
4//! API surface):
5//!
6//!   * [`spawn_daemon`] — detached lifetime, sanitized file-or-NUL stdio,
7//!     sanitized handle list, no console window, ignores parent's Ctrl-C. The
8//!     returned [`DaemonChild`] does NOT die when dropped.
9//!   * [`spawn`] — contained lifetime, caller-controlled stdio via
10//!     [`SpawnStdio`], sanitized handle list, no console window by default
11//!     (opt in via [`SpawnStdio::show_console`]), bounded drain. The returned
12//!     [`SpawnedChild`] kills the child on Drop.
13//!
14//! ## Sanitized handle inheritance
15//!
16//! Both modes inherit ONLY the three stdio handles we resolve here. On
17//! Windows we use `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` to whitelist exactly
18//! the resolved handles. On Unix the spawned child runs a `pre_exec` closure
19//! that walks `/proc/self/fd` (or `/dev/fd`) and closes every fd > 2.
20//!
21//! Motivation: when a process tree has a pipe-redirected ancestor (Python
22//! `subprocess.Popen(stdout=PIPE)`, IDE language-server hosts, CI runners,
23//! etc.), every intermediate `CreateProcessW(bInheritHandles=TRUE)` on
24//! Windows — and every `fork`+`exec` of a non-`O_CLOEXEC` fd on Unix —
25//! duplicates that orphaned pipe write-end into the new child. The original
26//! reader at the top never sees EOF.
27//!
28//! Issue: <https://github.com/zackees/running-process/issues/110>.
29
30#[cfg(unix)]
31use std::os::fd::BorrowedFd;
32#[cfg(windows)]
33use std::os::windows::io::BorrowedHandle;
34use std::process::Command;
35use std::time::Duration;
36
37/// Selects the base environment used for a newly spawned process.
38///
39/// Explicit mutations added through [`Command::env`], [`Command::envs`], or
40/// [`Command::env_remove`] are applied after the selected base and therefore
41/// always win.
42#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
43pub enum EnvironmentPolicy {
44    /// Choose from the process lifetime: contained subprocesses inherit,
45    /// while detached daemons start from the logged-in user's baseline.
46    #[default]
47    Auto,
48    /// Inherit the spawning process's environment.
49    Inherit,
50    /// Start from the logged-in user's machine + user environment, discarding
51    /// the spawning process's ambient environment except for the documented
52    /// Unix locale, time-zone, and temporary-directory allowlist.
53    ///
54    /// Windows implements this with `CreateEnvironmentBlock`. Unix
55    /// reconstructs a clean login environment from the user's identity
56    /// (`getpwuid_r` → `USER`/`LOGNAME`/`HOME`/`SHELL`, platform default
57    /// `PATH`, carried-over locale/`TZ`/`TMPDIR`), falling back to inheritance
58    /// only when the passwd entry cannot be resolved.
59    ///
60    /// Consumers that need values such as `CARGO_HOME`, `RUSTUP_HOME`,
61    /// `SOLDR_*`, credentials, or runner-specific paths must pass them
62    /// explicitly on the [`Command`].
63    UserBaseline,
64    /// Start from an empty environment.
65    Clear,
66}
67
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub(crate) enum SpawnLifetime {
70    Contained,
71    Daemon,
72}
73
74impl EnvironmentPolicy {
75    pub(crate) fn resolve(self, lifetime: SpawnLifetime) -> Self {
76        match (self, lifetime) {
77            (Self::Auto, SpawnLifetime::Contained) => Self::Inherit,
78            (Self::Auto, SpawnLifetime::Daemon) => Self::UserBaseline,
79            (explicit, _) => explicit,
80        }
81    }
82}
83
84// ── Public API ──────────────────────────────────────────────────────────────
85
86/// Caller-supplied stdio bindings for [`spawn`].
87///
88/// Each of `stdin`, `stdout`, `stderr` is independently a [`StdioSource`].
89/// `drain_timeout` bounds the post-mortem wait the watcher thread applies
90/// before force-closing any wrapper-held pipe ends so the parent observes
91/// EOF after the child exits. `None` means the wrapper never auto-closes;
92/// the parent is responsible for closing the pipes when it's done reading.
93///
94/// `show_console` (Windows-only effect) controls whether the child gets a
95/// console window. Default is `false` — `CREATE_NO_WINDOW` is set, so the
96/// child has no console regardless of how the parent was launched. Set this
97/// to `true` only when you actually want the child to inherit / allocate a
98/// console (interactive subprocess that should be visible to the user).
99pub struct SpawnStdio<'a> {
100    /// Source connected to the child's standard input.
101    pub stdin: StdioSource<'a>,
102    /// Source connected to the child's standard output.
103    pub stdout: StdioSource<'a>,
104    /// Source connected to the child's standard error.
105    pub stderr: StdioSource<'a>,
106    /// Maximum time the watcher waits before closing wrapper-held pipe ends.
107    pub drain_timeout: Option<Duration>,
108    /// Whether Windows children may inherit or allocate a visible console.
109    pub show_console: bool,
110}
111
112/// Creation policy for [`spawn_tokio`].
113///
114/// This compatibility entrypoint lets async daemons keep Tokio's pipe and
115/// wait APIs while making `running-process` the sole owner of child-creation
116/// policy. It defaults to contained, console-less children.
117#[cfg(feature = "client-async")]
118#[derive(Clone, Copy, Debug, Eq, PartialEq)]
119pub struct TokioSpawnOptions {
120    /// Terminate the child when Tokio's child handle is dropped.
121    pub kill_on_drop: bool,
122    /// Whether Windows children may inherit or allocate a visible console.
123    pub show_console: bool,
124    /// Kill this child at the OS level when the spawning process dies.
125    ///
126    /// - **Linux**: installs `PR_SET_PDEATHSIG(SIGTERM)` in the child.
127    /// - **Windows**: assigns the child to a process-wide `KILL_ON_JOB_CLOSE`
128    ///   Job Object, so the child (and its descendants) die when the spawner's
129    ///   handle to the job closes — i.e. when the spawner process exits.
130    /// - **macOS / other Unix**: best-effort no-op for now (running-process#885).
131    ///
132    /// `kill_on_drop` only fires if the spawner runs its `Drop`; this option
133    /// covers the crash / SIGKILL / taskkill case where `Drop` never runs. Use
134    /// for transient children of a long-lived process (e.g. a daemon's compiler
135    /// subprocesses) that must not outlive their owner.
136    pub kill_when_owner_dies: bool,
137}
138
139#[cfg(feature = "client-async")]
140impl Default for TokioSpawnOptions {
141    fn default() -> Self {
142        Self {
143            kill_on_drop: true,
144            show_console: false,
145            kill_when_owner_dies: false,
146        }
147    }
148}
149
150impl Default for SpawnStdio<'_> {
151    fn default() -> Self {
152        Self {
153            stdin: StdioSource::Null,
154            stdout: StdioSource::Parent,
155            stderr: StdioSource::Parent,
156            drain_timeout: Some(Duration::from_secs(2)),
157            show_console: false,
158        }
159    }
160}
161
162/// Caller-supplied output bindings for a detached daemon.
163///
164/// Detached children may write only to the platform null device or to
165/// caller-owned file handles. Parent stdio and anonymous pipes are
166/// intentionally unavailable: either can retain the launching process's
167/// lifetime or fail after that process exits. The child always receives a
168/// fresh inheritable duplicate, and the caller retains its original handle.
169pub struct DaemonStdio<'a> {
170    /// Source connected to the daemon's standard output.
171    pub stdout: DaemonStdioSource<'a>,
172    /// Source connected to the daemon's standard error.
173    pub stderr: DaemonStdioSource<'a>,
174}
175
176impl Default for DaemonStdio<'_> {
177    fn default() -> Self {
178        Self {
179            stdout: DaemonStdioSource::Null,
180            stderr: DaemonStdioSource::Null,
181        }
182    }
183}
184
185/// Safe output source for a detached daemon.
186pub enum DaemonStdioSource<'a> {
187    /// Connect this slot to the platform null device (`NUL` / `/dev/null`).
188    Null,
189    /// Bind this slot to a caller-owned OS handle. The wrapper duplicates the
190    /// handle into an inheritable copy for the child.
191    #[cfg(windows)]
192    Handle(BorrowedHandle<'a>),
193    /// Bind this slot to a caller-owned file descriptor. Equivalent to
194    /// `DaemonStdioSource::Handle` on Windows.
195    #[cfg(unix)]
196    Fd(BorrowedFd<'a>),
197    #[doc(hidden)]
198    _Phantom(std::marker::PhantomData<&'a ()>),
199}
200
201/// Per-slot source describing what the child should inherit for one of
202/// stdin / stdout / stderr.
203pub enum StdioSource<'a> {
204    /// Connect this slot to the platform null device (`NUL` / `/dev/null`).
205    Null,
206    /// Inherit the parent's corresponding standard handle. The kernel
207    /// receives a fresh inheritable duplicate; the parent's original slot
208    /// is untouched.
209    Parent,
210    /// Bind this slot to a caller-owned OS handle. The wrapper duplicates
211    /// the handle into an inheritable copy for the child; the caller
212    /// retains its own handle and is responsible for closing it.
213    #[cfg(windows)]
214    Handle(BorrowedHandle<'a>),
215    /// Bind this slot to a caller-owned file descriptor. Equivalent to
216    /// `StdioSource::Handle` on Unix.
217    #[cfg(unix)]
218    Fd(BorrowedFd<'a>),
219    /// Create a fresh anonymous pipe. The child gets one end; the parent
220    /// gets the other via [`SpawnedChild`]'s `stdin` / `stdout` / `stderr`
221    /// fields.
222    Pipe,
223    #[doc(hidden)]
224    _Phantom(std::marker::PhantomData<&'a ()>),
225}
226
227// _Phantom is uninhabitable from outside: PhantomData<&'a ()> is a private
228// constructor in practice (the variant is doc(hidden) and not constructed
229// anywhere in this crate). It's only here so the `'a` lifetime is always
230// used regardless of which cfg branch is active.
231
232/// Handle to a detached daemon spawned via [`spawn_daemon`].
233///
234/// The daemon child always has stdin connected to the platform null device.
235/// Stdout and stderr also default to null, but [`spawn_daemon_with_stdio`]
236/// can bind them to caller-owned files. A detached process can never inherit
237/// parent stdio or caller pipes through this API. Dropping `DaemonChild` does
238/// NOT terminate the daemon; it only closes the OS handle the wrapper held.
239/// Call [`DaemonChild::kill`] to terminate.
240pub struct DaemonChild {
241    pid: u32,
242    #[cfg(windows)]
243    handle: imp::OwnedHandle,
244    #[cfg(unix)]
245    child: std::process::Child,
246}
247
248impl DaemonChild {
249    /// Process ID.
250    pub fn id(&self) -> u32 {
251        self.pid
252    }
253
254    /// Forcibly terminate the child. Best-effort.
255    pub fn kill(&mut self) -> std::io::Result<()> {
256        #[cfg(windows)]
257        {
258            imp::terminate(&self.handle)
259        }
260        #[cfg(unix)]
261        {
262            self.child.kill()
263        }
264    }
265
266    /// Block until the child exits and return its exit code.
267    pub fn wait(&mut self) -> std::io::Result<i32> {
268        #[cfg(windows)]
269        {
270            imp::wait(&self.handle)
271        }
272        #[cfg(unix)]
273        {
274            let status = self.child.wait()?;
275            Ok(unix_exit_code(status))
276        }
277    }
278
279    /// Non-blocking variant of [`Self::wait`].
280    pub fn try_wait(&mut self) -> std::io::Result<Option<i32>> {
281        #[cfg(windows)]
282        {
283            imp::try_wait(&self.handle)
284        }
285        #[cfg(unix)]
286        {
287            Ok(self.child.try_wait()?.map(unix_exit_code))
288        }
289    }
290}
291
292/// Handle to a contained child spawned via [`spawn`].
293///
294/// On Drop, `SpawnedChild` synchronously kills the child:
295///   * Windows: closes the Job Object handle; `KILL_ON_JOB_CLOSE` causes the
296///     kernel to terminate every process in the job (the child and its
297///     descendants).
298///   * Unix: `killpg(pgid, SIGKILL)` and `waitpid` to reap.
299///
300/// The optional `stdin` / `stdout` / `stderr` fields are present when the
301/// corresponding [`StdioSource`] was [`StdioSource::Pipe`]; otherwise they
302/// are `None`.
303pub struct SpawnedChild {
304    /// Parent-side pipe for writing to child stdin when requested.
305    pub stdin: Option<std::process::ChildStdin>,
306    /// Parent-side pipe for reading child stdout when requested.
307    pub stdout: Option<std::process::ChildStdout>,
308    /// Parent-side pipe for reading child stderr when requested.
309    pub stderr: Option<std::process::ChildStderr>,
310    pid: u32,
311    #[cfg(windows)]
312    inner: imp::SpawnedInner,
313    #[cfg(unix)]
314    inner: unix_impl::SpawnedInner,
315}
316
317impl SpawnedChild {
318    /// Process ID of the spawned child.
319    pub fn id(&self) -> u32 {
320        self.pid
321    }
322
323    /// Forcibly terminate the child. Best-effort.
324    pub fn kill(&mut self) -> std::io::Result<()> {
325        #[cfg(windows)]
326        {
327            self.inner.kill()
328        }
329        #[cfg(unix)]
330        {
331            self.inner.kill()
332        }
333    }
334
335    /// Block until the child exits and return its exit code.
336    pub fn wait(&mut self) -> std::io::Result<i32> {
337        #[cfg(windows)]
338        {
339            self.inner.wait()
340        }
341        #[cfg(unix)]
342        {
343            self.inner.wait()
344        }
345    }
346
347    /// Non-blocking variant of [`Self::wait`].
348    pub fn try_wait(&mut self) -> std::io::Result<Option<i32>> {
349        #[cfg(windows)]
350        {
351            self.inner.try_wait()
352        }
353        #[cfg(unix)]
354        {
355            self.inner.try_wait()
356        }
357    }
358}
359
360impl Drop for SpawnedChild {
361    fn drop(&mut self) {
362        #[cfg(windows)]
363        {
364            self.inner.shutdown();
365        }
366        #[cfg(unix)]
367        {
368            self.inner.shutdown();
369        }
370    }
371}
372
373/// Set on every child spawned through the daemon path, so a process can be
374/// recognized as a *declared daemon* rather than inferred to be one.
375///
376/// # Why a positive marker
377///
378/// Reapers previously had to infer daemon-ness from the **absence** of
379/// [`crate::ORIGINATOR_ENV_VAR`], which `spawn_daemon` strips. But absence is
380/// overloaded: it means both "this process deliberately detached itself" and
381/// "something in the chain clobbered the environment" — and those are
382/// byte-identical at the observation point, so no amount of process-lineage
383/// tracking can separate them. See zackees/clud#522, where an
384/// ancestry-fallback proposal and a daemon exemption read the same signal and
385/// drew opposite conclusions.
386///
387/// A positive declaration removes the ambiguity: only a process that actually
388/// went through the daemon path carries this.
389///
390/// # Caveat
391///
392/// This is still an environment variable, so a chain that strips
393/// `RUNNING_PROCESS_ORIGINATOR` strips this too. It narrows the ambiguous case
394/// rather than eliminating it; a durable answer would need the daemon's
395/// supervisor to register the PID somewhere the reaper can read.
396///
397/// Distinct from `RUNNING_PROCESS_DAEMON_SCOPE`, which names a broker scope
398/// and is unrelated.
399pub const DAEMON_MARKER_ENV_VAR: &str = "RUNNING_PROCESS_IS_DAEMON";
400
401/// Spawn `command` as a detached daemon. NUL stdio, sanitized handles,
402/// no console window, ignores parent's Ctrl-C / SIGINT (Windows:
403/// `CREATE_NEW_PROCESS_GROUP` + `DETACHED_PROCESS`; Unix: `setsid` puts the
404/// daemon in a new session so it's not in the parent's foreground group).
405///
406/// Use [`spawn_daemon_with_stdio`] when the daemon must write to stable
407/// caller-owned files. Parent stdio and anonymous pipes remain unavailable
408/// for detached children.
409pub fn spawn_daemon(command: &mut Command) -> std::io::Result<DaemonChild> {
410    spawn_daemon_inner(
411        command,
412        DaemonStdio::default(),
413        EnvironmentPolicy::Auto,
414        false,
415    )
416}
417
418/// Spawn a detached daemon with file-or-NUL stdout and stderr.
419///
420/// Stdin remains connected to null. The supplied handles are duplicated into
421/// the sanitized child handle list, so the caller can close its files after
422/// this function returns without affecting the daemon.
423pub fn spawn_daemon_with_stdio(
424    command: &mut Command,
425    stdio: DaemonStdio<'_>,
426) -> std::io::Result<DaemonChild> {
427    spawn_daemon_with_stdio_and_env_policy(command, stdio, EnvironmentPolicy::Auto)
428}
429
430/// [`spawn_daemon_with_stdio`] with an explicit environment policy.
431pub fn spawn_daemon_with_stdio_and_env_policy(
432    command: &mut Command,
433    stdio: DaemonStdio<'_>,
434    policy: EnvironmentPolicy,
435) -> std::io::Result<DaemonChild> {
436    spawn_daemon_inner(command, stdio, policy, false)
437}
438
439/// Like [`spawn_daemon`] but with explicit control over whether the
440/// daemon's inherited env is passed through to the child.
441///
442/// `clear_env = false` uses [`EnvironmentPolicy::Auto`], matching
443/// [`spawn_daemon`].
444///
445/// `clear_env = true`: child sees ONLY the explicit `command.env(...)`
446/// entries. Mirrors `command.env_clear()` semantics for callers using
447/// the manual `CreateProcessW` path (Rust stdlib's `env_clear` flag
448/// isn't observable through `Command::get_envs`, so our sanitized
449/// spawn machinery can't otherwise honour it).
450pub fn spawn_daemon_with_clear_env(
451    command: &mut Command,
452    clear_env: bool,
453) -> std::io::Result<DaemonChild> {
454    let policy = if clear_env {
455        EnvironmentPolicy::Clear
456    } else {
457        EnvironmentPolicy::Auto
458    };
459    spawn_daemon_inner(command, DaemonStdio::default(), policy, false)
460}
461
462/// Spawn a detached daemon using an explicit environment policy.
463///
464/// [`EnvironmentPolicy::Auto`] resolves to
465/// [`EnvironmentPolicy::UserBaseline`] for daemons, excluding unlisted
466/// ambient variables. Use [`EnvironmentPolicy::Inherit`] as the explicit
467/// escape hatch for trusted callers that require the full parent environment.
468/// In every mode, explicit command environment additions, overrides, and
469/// removals are applied last.
470pub fn spawn_daemon_with_env_policy(
471    command: &mut Command,
472    policy: EnvironmentPolicy,
473) -> std::io::Result<DaemonChild> {
474    spawn_daemon_inner(command, DaemonStdio::default(), policy, false)
475}
476
477/// Like [`spawn_daemon`], but the child also **breaks away from any Job
478/// Object the spawner belongs to** (Windows; a no-op elsewhere).
479///
480/// Use this for a daemon that must outlive the process tree that happened to
481/// start it — a build cache server, a language server, anything discovered
482/// and reused by later, unrelated invocations.
483///
484/// # Why this is separate from [`spawn_daemon`]
485///
486/// "Detached lifetime" and "escapes my caller's containment" are different
487/// properties, and callers genuinely want them independently. Job Object
488/// membership is inherited by every descendant at any depth, and jobs created
489/// by this crate carry `KILL_ON_JOB_CLOSE` — so without breakaway the kernel
490/// terminates such a daemon the moment the spawner's job handle drops, no
491/// matter how detached the daemon made itself.
492///
493/// But making that unconditional breaks the opposite use: a child spawned as
494/// a daemon purely to obtain a sanitized handle list must stay inside the
495/// caller's job. `testbins/src/bin/spawner.rs` does exactly this, and
496/// `containment_test::test_contained_group_kills_grandchildren` fails if its
497/// sleepers escape.
498///
499/// # Refusal is not silent
500///
501/// `CREATE_BREAKAWAY_FROM_JOB` is *refused*, not ignored, when the spawner
502/// sits inside a job that lacks `JOB_OBJECT_LIMIT_BREAKAWAY_OK`:
503/// `CreateProcessW` fails with `ERROR_ACCESS_DENIED`. Outer jobs we do not
504/// control are common (CI runners, container supervisors, debuggers), so the
505/// spawn retries once with the flag cleared — a daemon that stays contained
506/// beats a daemon that fails to start.
507pub fn spawn_daemon_breaking_away_from_job(command: &mut Command) -> std::io::Result<DaemonChild> {
508    spawn_daemon_inner(
509        command,
510        DaemonStdio::default(),
511        EnvironmentPolicy::Auto,
512        true,
513    )
514}
515
516/// [`spawn_daemon_breaking_away_from_job`] with an explicit env policy.
517pub fn spawn_daemon_breaking_away_with_env_policy(
518    command: &mut Command,
519    policy: EnvironmentPolicy,
520) -> std::io::Result<DaemonChild> {
521    spawn_daemon_inner(command, DaemonStdio::default(), policy, true)
522}
523
524/// Apply the daemon self-declaration to `command`. Split out from
525/// [`spawn_daemon_inner`] so the policy is unit-testable without spawning a
526/// real detached process.
527pub(crate) fn mark_as_daemon(command: &mut Command) {
528    command.env(DAEMON_MARKER_ENV_VAR, "1");
529}
530
531fn spawn_daemon_inner(
532    command: &mut Command,
533    stdio: DaemonStdio<'_>,
534    policy: EnvironmentPolicy,
535    breakaway: bool,
536) -> std::io::Result<DaemonChild> {
537    // Every daemon-spawn variant funnels through here, so this is the one
538    // place that can mark them all — including the free functions consumers
539    // like zccache call directly.
540    mark_as_daemon(command);
541    let policy = policy.resolve(SpawnLifetime::Daemon);
542    #[cfg(windows)]
543    {
544        imp::spawn_daemon(command, stdio, policy, breakaway)
545    }
546    #[cfg(unix)]
547    {
548        // Unix has no Job Object; `setsid` already detaches the daemon from
549        // the parent's session and process group, so breakaway is moot.
550        let _ = breakaway;
551        unix_impl::spawn_daemon(command, stdio, policy)
552    }
553}
554
555/// Spawn `command` as a contained child with caller-controlled stdio.
556/// Sanitized handles, and no console (`DETACHED_PROCESS` on Windows). Child
557/// dies when the returned
558/// [`SpawnedChild`] is dropped.
559pub fn spawn(command: &mut Command, stdio: SpawnStdio<'_>) -> std::io::Result<SpawnedChild> {
560    spawn_with_env_policy(command, stdio, EnvironmentPolicy::Auto)
561}
562
563/// Spawn a contained child using an explicit environment policy.
564pub fn spawn_with_env_policy(
565    command: &mut Command,
566    stdio: SpawnStdio<'_>,
567    policy: EnvironmentPolicy,
568) -> std::io::Result<SpawnedChild> {
569    let policy = policy.resolve(SpawnLifetime::Contained);
570    #[cfg(windows)]
571    {
572        imp::spawn(command, stdio, policy)
573    }
574    #[cfg(unix)]
575    {
576        unix_impl::spawn(command, stdio, policy)
577    }
578}
579
580/// Spawn a Tokio child through the centralized process-creation boundary.
581///
582/// Callers retain Tokio's async stdin/stdout/stderr and wait APIs, but may not
583/// apply platform creation flags themselves. On Windows, console suppression
584/// is owned here. Use [`spawn`] when the stronger sanitized-handle-list and
585/// kill-on-close Job Object contract is required.
586#[cfg(feature = "client-async")]
587pub fn spawn_tokio(
588    command: &mut tokio::process::Command,
589    options: TokioSpawnOptions,
590) -> std::io::Result<tokio::process::Child> {
591    command.kill_on_drop(options.kill_on_drop);
592    #[cfg(windows)]
593    command.creation_flags(tokio_creation_flags(options.show_console));
594    #[cfg(not(windows))]
595    let _ = options.show_console;
596
597    // Linux: link the child's death to the spawner's via PR_SET_PDEATHSIG,
598    // installed in the child just before exec. macOS/other Unix have no
599    // equivalent primitive, so this is a no-op there (running-process#885).
600    #[cfg(target_os = "linux")]
601    if options.kill_when_owner_dies {
602        use std::os::unix::process::CommandExt;
603        // SAFETY: the closure calls only prctl(2), which is async-signal-safe.
604        unsafe {
605            command.pre_exec(|| {
606                let rc = libc::prctl(
607                    libc::PR_SET_PDEATHSIG,
608                    libc::SIGTERM as libc::c_ulong,
609                    0,
610                    0,
611                    0,
612                );
613                if rc == -1 {
614                    return Err(std::io::Error::last_os_error());
615                }
616                Ok(())
617            });
618        }
619    }
620
621    let child = command.spawn()?;
622
623    // Windows: place the child in the process-wide kill-on-close job so the OS
624    // reaps it (and its descendants) when the spawner exits — including a
625    // SIGKILL/taskkill that skips `Drop`/`kill_on_drop`.
626    #[cfg(windows)]
627    if options.kill_when_owner_dies {
628        if let Some(handle) = child.raw_handle() {
629            owner_death_job::assign(handle);
630        }
631    }
632    #[cfg(not(any(windows, target_os = "linux")))]
633    let _ = options.kill_when_owner_dies;
634
635    Ok(child)
636}
637
638/// Process-wide `KILL_ON_JOB_CLOSE` job used by
639/// [`TokioSpawnOptions::kill_when_owner_dies`] on Windows. Children assigned to
640/// it die when this process's handle to the job closes, i.e. when this process
641/// exits — the crash/taskkill path that `kill_on_drop` cannot cover.
642#[cfg(all(windows, feature = "client-async"))]
643mod owner_death_job {
644    use std::os::windows::io::RawHandle;
645    use std::sync::OnceLock;
646    use winapi::shared::minwindef::DWORD;
647    use winapi::um::jobapi2::{
648        AssignProcessToJobObject, CreateJobObjectW, SetInformationJobObject,
649    };
650    use winapi::um::winnt::{
651        JobObjectExtendedLimitInformation, HANDLE, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
652        JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
653    };
654
655    struct Job(HANDLE);
656    // The handle is used only via AssignProcessToJobObject and is never freed
657    // (its closure on process exit is the whole point).
658    unsafe impl Send for Job {}
659    unsafe impl Sync for Job {}
660
661    static JOB: OnceLock<Option<Job>> = OnceLock::new();
662
663    fn create() -> Option<Job> {
664        unsafe {
665            let handle = CreateJobObjectW(std::ptr::null_mut(), std::ptr::null());
666            if handle.is_null() {
667                return None;
668            }
669            let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
670            info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
671            let ok = SetInformationJobObject(
672                handle,
673                JobObjectExtendedLimitInformation,
674                &mut info as *mut _ as *mut _,
675                std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as DWORD,
676            );
677            if ok == 0 {
678                return None;
679            }
680            Some(Job(handle))
681        }
682    }
683
684    pub(super) fn assign(child: RawHandle) {
685        let Some(job) = JOB.get_or_init(create).as_ref() else {
686            return;
687        };
688        // Best-effort: a child that already belongs to a no-breakaway job, or
689        // has exited, cannot be assigned — neither is worth failing the spawn.
690        unsafe {
691            AssignProcessToJobObject(job.0, child as HANDLE);
692        }
693    }
694}
695
696#[cfg(all(feature = "client-async", windows))]
697fn tokio_creation_flags(show_console: bool) -> u32 {
698    if show_console {
699        0
700    } else {
701        // CREATE_NO_WINDOW. Keep this policy private so consumers cannot
702        // duplicate or partially apply Windows creation flags.
703        0x0800_0000
704    }
705}
706
707#[cfg(unix)]
708fn unix_exit_code(status: std::process::ExitStatus) -> i32 {
709    use std::os::unix::process::ExitStatusExt;
710    status
711        .code()
712        .unwrap_or_else(|| -status.signal().unwrap_or(1))
713}
714
715// ── Windows implementation ──────────────────────────────────────────────────
716
717#[cfg(windows)]
718#[path = "spawn_imp_windows.rs"]
719mod imp;
720
721#[cfg(unix)]
722#[path = "spawn_imp_unix.rs"]
723mod unix_impl;
724#[cfg(test)]
725mod tests {
726    use super::*;
727
728    #[cfg(feature = "client-async")]
729    #[test]
730    fn kill_when_owner_dies_defaults_off() {
731        // Opt-in only — existing callers keep today's behavior.
732        assert!(!TokioSpawnOptions::default().kill_when_owner_dies);
733    }
734
735    #[test]
736    fn spawn_stdio_default_has_sane_values() {
737        let s = SpawnStdio::default();
738        assert!(matches!(s.stdin, StdioSource::Null));
739        assert!(matches!(s.stdout, StdioSource::Parent));
740        assert!(matches!(s.stderr, StdioSource::Parent));
741        assert_eq!(s.drain_timeout, Some(Duration::from_secs(2)));
742        // No console window by default — opt-in only.
743        assert!(!s.show_console);
744    }
745
746    #[test]
747    fn daemon_stdio_default_is_null() {
748        let stdio = DaemonStdio::default();
749        assert!(matches!(stdio.stdout, DaemonStdioSource::Null));
750        assert!(matches!(stdio.stderr, DaemonStdioSource::Null));
751    }
752
753    #[test]
754    fn auto_environment_policy_depends_on_lifetime() {
755        assert_eq!(
756            EnvironmentPolicy::Auto.resolve(SpawnLifetime::Contained),
757            EnvironmentPolicy::Inherit
758        );
759        assert_eq!(
760            EnvironmentPolicy::Auto.resolve(SpawnLifetime::Daemon),
761            EnvironmentPolicy::UserBaseline
762        );
763    }
764
765    #[test]
766    fn explicit_environment_policy_is_not_rewritten() {
767        for policy in [
768            EnvironmentPolicy::Inherit,
769            EnvironmentPolicy::UserBaseline,
770            EnvironmentPolicy::Clear,
771        ] {
772            assert_eq!(policy.resolve(SpawnLifetime::Contained), policy);
773            assert_eq!(policy.resolve(SpawnLifetime::Daemon), policy);
774        }
775    }
776
777    #[cfg(feature = "client-async")]
778    #[test]
779    fn tokio_spawn_defaults_to_contained_consoleless_children() {
780        assert_eq!(
781            TokioSpawnOptions::default(),
782            TokioSpawnOptions {
783                kill_on_drop: true,
784                show_console: false,
785                kill_when_owner_dies: false,
786            }
787        );
788    }
789
790    #[cfg(all(feature = "client-async", windows))]
791    #[test]
792    fn tokio_spawn_owns_console_creation_flags() {
793        assert_eq!(tokio_creation_flags(false), 0x0800_0000);
794        assert_eq!(tokio_creation_flags(true), 0);
795    }
796}