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, NUL stdio, sanitized handle list,
7//!     no console window, ignores parent's Ctrl-C. The returned [`DaemonChild`]
8//!     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
112impl Default for SpawnStdio<'_> {
113    fn default() -> Self {
114        Self {
115            stdin: StdioSource::Null,
116            stdout: StdioSource::Parent,
117            stderr: StdioSource::Parent,
118            drain_timeout: Some(Duration::from_secs(2)),
119            show_console: false,
120        }
121    }
122}
123
124/// Per-slot source describing what the child should inherit for one of
125/// stdin / stdout / stderr.
126pub enum StdioSource<'a> {
127    /// Connect this slot to the platform null device (`NUL` / `/dev/null`).
128    Null,
129    /// Inherit the parent's corresponding standard handle. The kernel
130    /// receives a fresh inheritable duplicate; the parent's original slot
131    /// is untouched.
132    Parent,
133    /// Bind this slot to a caller-owned OS handle. The wrapper duplicates
134    /// the handle into an inheritable copy for the child; the caller
135    /// retains its own handle and is responsible for closing it.
136    #[cfg(windows)]
137    Handle(BorrowedHandle<'a>),
138    /// Bind this slot to a caller-owned file descriptor. Equivalent to
139    /// `StdioSource::Handle` on Unix.
140    #[cfg(unix)]
141    Fd(BorrowedFd<'a>),
142    /// Create a fresh anonymous pipe. The child gets one end; the parent
143    /// gets the other via [`SpawnedChild`]'s `stdin` / `stdout` / `stderr`
144    /// fields.
145    Pipe,
146    #[doc(hidden)]
147    _Phantom(std::marker::PhantomData<&'a ()>),
148}
149
150// _Phantom is uninhabitable from outside: PhantomData<&'a ()> is a private
151// constructor in practice (the variant is doc(hidden) and not constructed
152// anywhere in this crate). It's only here so the `'a` lifetime is always
153// used regardless of which cfg branch is active.
154
155/// Handle to a detached daemon spawned via [`spawn_daemon`].
156///
157/// The daemon child always has stdin/stdout/stderr connected to the
158/// platform null device (`NUL` on Windows, `/dev/null` on Unix) — a
159/// detached process with inherited stdio is the classic crash-on-first-
160/// `println!` failure mode after the parent closes its end, so the
161/// daemon-spawn path forecloses that by construction. Dropping
162/// `DaemonChild` does NOT terminate the daemon; it only closes the OS
163/// handle the wrapper held. Call [`DaemonChild::kill`] to terminate.
164pub struct DaemonChild {
165    pid: u32,
166    #[cfg(windows)]
167    handle: imp::OwnedHandle,
168    #[cfg(unix)]
169    child: std::process::Child,
170}
171
172impl DaemonChild {
173    /// Process ID.
174    pub fn id(&self) -> u32 {
175        self.pid
176    }
177
178    /// Forcibly terminate the child. Best-effort.
179    pub fn kill(&mut self) -> std::io::Result<()> {
180        #[cfg(windows)]
181        {
182            imp::terminate(&self.handle)
183        }
184        #[cfg(unix)]
185        {
186            self.child.kill()
187        }
188    }
189
190    /// Block until the child exits and return its exit code.
191    pub fn wait(&mut self) -> std::io::Result<i32> {
192        #[cfg(windows)]
193        {
194            imp::wait(&self.handle)
195        }
196        #[cfg(unix)]
197        {
198            let status = self.child.wait()?;
199            Ok(unix_exit_code(status))
200        }
201    }
202
203    /// Non-blocking variant of [`Self::wait`].
204    pub fn try_wait(&mut self) -> std::io::Result<Option<i32>> {
205        #[cfg(windows)]
206        {
207            imp::try_wait(&self.handle)
208        }
209        #[cfg(unix)]
210        {
211            Ok(self.child.try_wait()?.map(unix_exit_code))
212        }
213    }
214}
215
216/// Handle to a contained child spawned via [`spawn`].
217///
218/// On Drop, `SpawnedChild` synchronously kills the child:
219///   * Windows: closes the Job Object handle; `KILL_ON_JOB_CLOSE` causes the
220///     kernel to terminate every process in the job (the child and its
221///     descendants).
222///   * Unix: `killpg(pgid, SIGKILL)` and `waitpid` to reap.
223///
224/// The optional `stdin` / `stdout` / `stderr` fields are present when the
225/// corresponding [`StdioSource`] was [`StdioSource::Pipe`]; otherwise they
226/// are `None`.
227pub struct SpawnedChild {
228    /// Parent-side pipe for writing to child stdin when requested.
229    pub stdin: Option<std::process::ChildStdin>,
230    /// Parent-side pipe for reading child stdout when requested.
231    pub stdout: Option<std::process::ChildStdout>,
232    /// Parent-side pipe for reading child stderr when requested.
233    pub stderr: Option<std::process::ChildStderr>,
234    pid: u32,
235    #[cfg(windows)]
236    inner: imp::SpawnedInner,
237    #[cfg(unix)]
238    inner: unix_impl::SpawnedInner,
239}
240
241impl SpawnedChild {
242    /// Process ID of the spawned child.
243    pub fn id(&self) -> u32 {
244        self.pid
245    }
246
247    /// Forcibly terminate the child. Best-effort.
248    pub fn kill(&mut self) -> std::io::Result<()> {
249        #[cfg(windows)]
250        {
251            self.inner.kill()
252        }
253        #[cfg(unix)]
254        {
255            self.inner.kill()
256        }
257    }
258
259    /// Block until the child exits and return its exit code.
260    pub fn wait(&mut self) -> std::io::Result<i32> {
261        #[cfg(windows)]
262        {
263            self.inner.wait()
264        }
265        #[cfg(unix)]
266        {
267            self.inner.wait()
268        }
269    }
270
271    /// Non-blocking variant of [`Self::wait`].
272    pub fn try_wait(&mut self) -> std::io::Result<Option<i32>> {
273        #[cfg(windows)]
274        {
275            self.inner.try_wait()
276        }
277        #[cfg(unix)]
278        {
279            self.inner.try_wait()
280        }
281    }
282}
283
284impl Drop for SpawnedChild {
285    fn drop(&mut self) {
286        #[cfg(windows)]
287        {
288            self.inner.shutdown();
289        }
290        #[cfg(unix)]
291        {
292            self.inner.shutdown();
293        }
294    }
295}
296
297/// Set on every child spawned through the daemon path, so a process can be
298/// recognized as a *declared daemon* rather than inferred to be one.
299///
300/// # Why a positive marker
301///
302/// Reapers previously had to infer daemon-ness from the **absence** of
303/// [`crate::ORIGINATOR_ENV_VAR`], which `spawn_daemon` strips. But absence is
304/// overloaded: it means both "this process deliberately detached itself" and
305/// "something in the chain clobbered the environment" — and those are
306/// byte-identical at the observation point, so no amount of process-lineage
307/// tracking can separate them. See zackees/clud#522, where an
308/// ancestry-fallback proposal and a daemon exemption read the same signal and
309/// drew opposite conclusions.
310///
311/// A positive declaration removes the ambiguity: only a process that actually
312/// went through the daemon path carries this.
313///
314/// # Caveat
315///
316/// This is still an environment variable, so a chain that strips
317/// `RUNNING_PROCESS_ORIGINATOR` strips this too. It narrows the ambiguous case
318/// rather than eliminating it; a durable answer would need the daemon's
319/// supervisor to register the PID somewhere the reaper can read.
320///
321/// Distinct from `RUNNING_PROCESS_DAEMON_SCOPE`, which names a broker scope
322/// and is unrelated.
323pub const DAEMON_MARKER_ENV_VAR: &str = "RUNNING_PROCESS_IS_DAEMON";
324
325/// Spawn `command` as a detached daemon. NUL stdio, sanitized handles,
326/// no console window, ignores parent's Ctrl-C / SIGINT (Windows:
327/// `CREATE_NEW_PROCESS_GROUP` + `DETACHED_PROCESS`; Unix: `setsid` puts the
328/// daemon in a new session so it's not in the parent's foreground group).
329///
330/// The NUL-stdio guarantee is enforced internally by the platform impls
331/// and is not configurable — a detached daemon needs sunk stdio to
332/// avoid crashing on later `println!`/`eprintln!` after the parent
333/// closes its handles.
334pub fn spawn_daemon(command: &mut Command) -> std::io::Result<DaemonChild> {
335    spawn_daemon_with_env_policy(command, EnvironmentPolicy::Auto)
336}
337
338/// Like [`spawn_daemon`] but with explicit control over whether the
339/// daemon's inherited env is passed through to the child.
340///
341/// `clear_env = false` uses [`EnvironmentPolicy::Auto`], matching
342/// [`spawn_daemon`].
343///
344/// `clear_env = true`: child sees ONLY the explicit `command.env(...)`
345/// entries. Mirrors `command.env_clear()` semantics for callers using
346/// the manual `CreateProcessW` path (Rust stdlib's `env_clear` flag
347/// isn't observable through `Command::get_envs`, so our sanitized
348/// spawn machinery can't otherwise honour it).
349pub fn spawn_daemon_with_clear_env(
350    command: &mut Command,
351    clear_env: bool,
352) -> std::io::Result<DaemonChild> {
353    let policy = if clear_env {
354        EnvironmentPolicy::Clear
355    } else {
356        EnvironmentPolicy::Auto
357    };
358    spawn_daemon_with_env_policy(command, policy)
359}
360
361/// Spawn a detached daemon using an explicit environment policy.
362///
363/// [`EnvironmentPolicy::Auto`] resolves to
364/// [`EnvironmentPolicy::UserBaseline`] for daemons, excluding unlisted
365/// ambient variables. Use [`EnvironmentPolicy::Inherit`] as the explicit
366/// escape hatch for trusted callers that require the full parent environment.
367/// In every mode, explicit command environment additions, overrides, and
368/// removals are applied last.
369pub fn spawn_daemon_with_env_policy(
370    command: &mut Command,
371    policy: EnvironmentPolicy,
372) -> std::io::Result<DaemonChild> {
373    spawn_daemon_inner(command, policy, false)
374}
375
376/// Like [`spawn_daemon`], but the child also **breaks away from any Job
377/// Object the spawner belongs to** (Windows; a no-op elsewhere).
378///
379/// Use this for a daemon that must outlive the process tree that happened to
380/// start it — a build cache server, a language server, anything discovered
381/// and reused by later, unrelated invocations.
382///
383/// # Why this is separate from [`spawn_daemon`]
384///
385/// "Detached lifetime" and "escapes my caller's containment" are different
386/// properties, and callers genuinely want them independently. Job Object
387/// membership is inherited by every descendant at any depth, and jobs created
388/// by this crate carry `KILL_ON_JOB_CLOSE` — so without breakaway the kernel
389/// terminates such a daemon the moment the spawner's job handle drops, no
390/// matter how detached the daemon made itself.
391///
392/// But making that unconditional breaks the opposite use: a child spawned as
393/// a daemon purely to obtain a sanitized handle list must stay inside the
394/// caller's job. `testbins/src/bin/spawner.rs` does exactly this, and
395/// `containment_test::test_contained_group_kills_grandchildren` fails if its
396/// sleepers escape.
397///
398/// # Refusal is not silent
399///
400/// `CREATE_BREAKAWAY_FROM_JOB` is *refused*, not ignored, when the spawner
401/// sits inside a job that lacks `JOB_OBJECT_LIMIT_BREAKAWAY_OK`:
402/// `CreateProcessW` fails with `ERROR_ACCESS_DENIED`. Outer jobs we do not
403/// control are common (CI runners, container supervisors, debuggers), so the
404/// spawn retries once with the flag cleared — a daemon that stays contained
405/// beats a daemon that fails to start.
406pub fn spawn_daemon_breaking_away_from_job(command: &mut Command) -> std::io::Result<DaemonChild> {
407    spawn_daemon_inner(command, EnvironmentPolicy::Auto, true)
408}
409
410/// [`spawn_daemon_breaking_away_from_job`] with an explicit env policy.
411pub fn spawn_daemon_breaking_away_with_env_policy(
412    command: &mut Command,
413    policy: EnvironmentPolicy,
414) -> std::io::Result<DaemonChild> {
415    spawn_daemon_inner(command, policy, true)
416}
417
418/// Apply the daemon self-declaration to `command`. Split out from
419/// [`spawn_daemon_inner`] so the policy is unit-testable without spawning a
420/// real detached process.
421pub(crate) fn mark_as_daemon(command: &mut Command) {
422    command.env(DAEMON_MARKER_ENV_VAR, "1");
423}
424
425fn spawn_daemon_inner(
426    command: &mut Command,
427    policy: EnvironmentPolicy,
428    breakaway: bool,
429) -> std::io::Result<DaemonChild> {
430    // Every daemon-spawn variant funnels through here, so this is the one
431    // place that can mark them all — including the free functions consumers
432    // like zccache call directly.
433    mark_as_daemon(command);
434    let policy = policy.resolve(SpawnLifetime::Daemon);
435    #[cfg(windows)]
436    {
437        imp::spawn_daemon(command, policy, breakaway)
438    }
439    #[cfg(unix)]
440    {
441        // Unix has no Job Object; `setsid` already detaches the daemon from
442        // the parent's session and process group, so breakaway is moot.
443        let _ = breakaway;
444        unix_impl::spawn_daemon(command, policy)
445    }
446}
447
448/// Spawn `command` as a contained child with caller-controlled stdio.
449/// Sanitized handles, CREATE_NO_WINDOW. Child dies when the returned
450/// [`SpawnedChild`] is dropped.
451pub fn spawn(command: &mut Command, stdio: SpawnStdio<'_>) -> std::io::Result<SpawnedChild> {
452    spawn_with_env_policy(command, stdio, EnvironmentPolicy::Auto)
453}
454
455/// Spawn a contained child using an explicit environment policy.
456pub fn spawn_with_env_policy(
457    command: &mut Command,
458    stdio: SpawnStdio<'_>,
459    policy: EnvironmentPolicy,
460) -> std::io::Result<SpawnedChild> {
461    let policy = policy.resolve(SpawnLifetime::Contained);
462    #[cfg(windows)]
463    {
464        imp::spawn(command, stdio, policy)
465    }
466    #[cfg(unix)]
467    {
468        unix_impl::spawn(command, stdio, policy)
469    }
470}
471
472#[cfg(unix)]
473fn unix_exit_code(status: std::process::ExitStatus) -> i32 {
474    use std::os::unix::process::ExitStatusExt;
475    status
476        .code()
477        .unwrap_or_else(|| -status.signal().unwrap_or(1))
478}
479
480// ── Windows implementation ──────────────────────────────────────────────────
481
482#[cfg(windows)]
483#[path = "spawn_imp_windows.rs"]
484mod imp;
485
486#[cfg(unix)]
487#[path = "spawn_imp_unix.rs"]
488mod unix_impl;
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    #[test]
494    fn spawn_stdio_default_has_sane_values() {
495        let s = SpawnStdio::default();
496        assert!(matches!(s.stdin, StdioSource::Null));
497        assert!(matches!(s.stdout, StdioSource::Parent));
498        assert!(matches!(s.stderr, StdioSource::Parent));
499        assert_eq!(s.drain_timeout, Some(Duration::from_secs(2)));
500        // No console window by default — opt-in only.
501        assert!(!s.show_console);
502    }
503
504    #[test]
505    fn auto_environment_policy_depends_on_lifetime() {
506        assert_eq!(
507            EnvironmentPolicy::Auto.resolve(SpawnLifetime::Contained),
508            EnvironmentPolicy::Inherit
509        );
510        assert_eq!(
511            EnvironmentPolicy::Auto.resolve(SpawnLifetime::Daemon),
512            EnvironmentPolicy::UserBaseline
513        );
514    }
515
516    #[test]
517    fn explicit_environment_policy_is_not_rewritten() {
518        for policy in [
519            EnvironmentPolicy::Inherit,
520            EnvironmentPolicy::UserBaseline,
521            EnvironmentPolicy::Clear,
522        ] {
523            assert_eq!(policy.resolve(SpawnLifetime::Contained), policy);
524            assert_eq!(policy.resolve(SpawnLifetime::Daemon), policy);
525        }
526    }
527}