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 values added through [`Command::env`] or [`Command::envs`]
40/// are applied after the selected base and therefore win on duplicate keys.
41#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
42pub enum EnvironmentPolicy {
43    /// Choose from the process lifetime: contained subprocesses inherit,
44    /// while detached daemons start from the logged-in user's baseline.
45    #[default]
46    Auto,
47    /// Inherit the spawning process's environment.
48    Inherit,
49    /// Start from the logged-in user's machine + user environment.
50    ///
51    /// Windows implements this with `CreateEnvironmentBlock`. Unix has no
52    /// equivalent stable OS API, so it currently falls back to inheritance.
53    UserBaseline,
54    /// Start from an empty environment.
55    Clear,
56}
57
58#[derive(Clone, Copy, Debug, Eq, PartialEq)]
59pub(crate) enum SpawnLifetime {
60    Contained,
61    Daemon,
62}
63
64impl EnvironmentPolicy {
65    pub(crate) fn resolve(self, lifetime: SpawnLifetime) -> Self {
66        match (self, lifetime) {
67            (Self::Auto, SpawnLifetime::Contained) => Self::Inherit,
68            (Self::Auto, SpawnLifetime::Daemon) => Self::UserBaseline,
69            (explicit, _) => explicit,
70        }
71    }
72}
73
74// ── Public API ──────────────────────────────────────────────────────────────
75
76/// Caller-supplied stdio bindings for [`spawn`].
77///
78/// Each of `stdin`, `stdout`, `stderr` is independently a [`StdioSource`].
79/// `drain_timeout` bounds the post-mortem wait the watcher thread applies
80/// before force-closing any wrapper-held pipe ends so the parent observes
81/// EOF after the child exits. `None` means the wrapper never auto-closes;
82/// the parent is responsible for closing the pipes when it's done reading.
83///
84/// `show_console` (Windows-only effect) controls whether the child gets a
85/// console window. Default is `false` — `CREATE_NO_WINDOW` is set, so the
86/// child has no console regardless of how the parent was launched. Set this
87/// to `true` only when you actually want the child to inherit / allocate a
88/// console (interactive subprocess that should be visible to the user).
89pub struct SpawnStdio<'a> {
90    /// Source connected to the child's standard input.
91    pub stdin: StdioSource<'a>,
92    /// Source connected to the child's standard output.
93    pub stdout: StdioSource<'a>,
94    /// Source connected to the child's standard error.
95    pub stderr: StdioSource<'a>,
96    /// Maximum time the watcher waits before closing wrapper-held pipe ends.
97    pub drain_timeout: Option<Duration>,
98    /// Whether Windows children may inherit or allocate a visible console.
99    pub show_console: bool,
100}
101
102impl Default for SpawnStdio<'_> {
103    fn default() -> Self {
104        Self {
105            stdin: StdioSource::Null,
106            stdout: StdioSource::Parent,
107            stderr: StdioSource::Parent,
108            drain_timeout: Some(Duration::from_secs(2)),
109            show_console: false,
110        }
111    }
112}
113
114/// Per-slot source describing what the child should inherit for one of
115/// stdin / stdout / stderr.
116pub enum StdioSource<'a> {
117    /// Connect this slot to the platform null device (`NUL` / `/dev/null`).
118    Null,
119    /// Inherit the parent's corresponding standard handle. The kernel
120    /// receives a fresh inheritable duplicate; the parent's original slot
121    /// is untouched.
122    Parent,
123    /// Bind this slot to a caller-owned OS handle. The wrapper duplicates
124    /// the handle into an inheritable copy for the child; the caller
125    /// retains its own handle and is responsible for closing it.
126    #[cfg(windows)]
127    Handle(BorrowedHandle<'a>),
128    /// Bind this slot to a caller-owned file descriptor. Equivalent to
129    /// `StdioSource::Handle` on Unix.
130    #[cfg(unix)]
131    Fd(BorrowedFd<'a>),
132    /// Create a fresh anonymous pipe. The child gets one end; the parent
133    /// gets the other via [`SpawnedChild`]'s `stdin` / `stdout` / `stderr`
134    /// fields.
135    Pipe,
136    #[doc(hidden)]
137    _Phantom(std::marker::PhantomData<&'a ()>),
138}
139
140// _Phantom is uninhabitable from outside: PhantomData<&'a ()> is a private
141// constructor in practice (the variant is doc(hidden) and not constructed
142// anywhere in this crate). It's only here so the `'a` lifetime is always
143// used regardless of which cfg branch is active.
144
145/// Handle to a detached daemon spawned via [`spawn_daemon`].
146///
147/// The daemon child always has stdin/stdout/stderr connected to the
148/// platform null device (`NUL` on Windows, `/dev/null` on Unix) — a
149/// detached process with inherited stdio is the classic crash-on-first-
150/// `println!` failure mode after the parent closes its end, so the
151/// daemon-spawn path forecloses that by construction. Dropping
152/// `DaemonChild` does NOT terminate the daemon; it only closes the OS
153/// handle the wrapper held. Call [`DaemonChild::kill`] to terminate.
154pub struct DaemonChild {
155    pid: u32,
156    #[cfg(windows)]
157    handle: imp::OwnedHandle,
158    #[cfg(unix)]
159    child: std::process::Child,
160}
161
162impl DaemonChild {
163    /// Process ID.
164    pub fn id(&self) -> u32 {
165        self.pid
166    }
167
168    /// Forcibly terminate the child. Best-effort.
169    pub fn kill(&mut self) -> std::io::Result<()> {
170        #[cfg(windows)]
171        {
172            imp::terminate(&self.handle)
173        }
174        #[cfg(unix)]
175        {
176            self.child.kill()
177        }
178    }
179
180    /// Block until the child exits and return its exit code.
181    pub fn wait(&mut self) -> std::io::Result<i32> {
182        #[cfg(windows)]
183        {
184            imp::wait(&self.handle)
185        }
186        #[cfg(unix)]
187        {
188            let status = self.child.wait()?;
189            Ok(unix_exit_code(status))
190        }
191    }
192
193    /// Non-blocking variant of [`Self::wait`].
194    pub fn try_wait(&mut self) -> std::io::Result<Option<i32>> {
195        #[cfg(windows)]
196        {
197            imp::try_wait(&self.handle)
198        }
199        #[cfg(unix)]
200        {
201            Ok(self.child.try_wait()?.map(unix_exit_code))
202        }
203    }
204}
205
206/// Handle to a contained child spawned via [`spawn`].
207///
208/// On Drop, `SpawnedChild` synchronously kills the child:
209///   * Windows: closes the Job Object handle; `KILL_ON_JOB_CLOSE` causes the
210///     kernel to terminate every process in the job (the child and its
211///     descendants).
212///   * Unix: `killpg(pgid, SIGKILL)` and `waitpid` to reap.
213///
214/// The optional `stdin` / `stdout` / `stderr` fields are present when the
215/// corresponding [`StdioSource`] was [`StdioSource::Pipe`]; otherwise they
216/// are `None`.
217pub struct SpawnedChild {
218    /// Parent-side pipe for writing to child stdin when requested.
219    pub stdin: Option<std::process::ChildStdin>,
220    /// Parent-side pipe for reading child stdout when requested.
221    pub stdout: Option<std::process::ChildStdout>,
222    /// Parent-side pipe for reading child stderr when requested.
223    pub stderr: Option<std::process::ChildStderr>,
224    pid: u32,
225    #[cfg(windows)]
226    inner: imp::SpawnedInner,
227    #[cfg(unix)]
228    inner: unix_impl::SpawnedInner,
229}
230
231impl SpawnedChild {
232    /// Process ID of the spawned child.
233    pub fn id(&self) -> u32 {
234        self.pid
235    }
236
237    /// Forcibly terminate the child. Best-effort.
238    pub fn kill(&mut self) -> std::io::Result<()> {
239        #[cfg(windows)]
240        {
241            self.inner.kill()
242        }
243        #[cfg(unix)]
244        {
245            self.inner.kill()
246        }
247    }
248
249    /// Block until the child exits and return its exit code.
250    pub fn wait(&mut self) -> std::io::Result<i32> {
251        #[cfg(windows)]
252        {
253            self.inner.wait()
254        }
255        #[cfg(unix)]
256        {
257            self.inner.wait()
258        }
259    }
260
261    /// Non-blocking variant of [`Self::wait`].
262    pub fn try_wait(&mut self) -> std::io::Result<Option<i32>> {
263        #[cfg(windows)]
264        {
265            self.inner.try_wait()
266        }
267        #[cfg(unix)]
268        {
269            self.inner.try_wait()
270        }
271    }
272}
273
274impl Drop for SpawnedChild {
275    fn drop(&mut self) {
276        #[cfg(windows)]
277        {
278            self.inner.shutdown();
279        }
280        #[cfg(unix)]
281        {
282            self.inner.shutdown();
283        }
284    }
285}
286
287/// Spawn `command` as a detached daemon. NUL stdio, sanitized handles,
288/// no console window, ignores parent's Ctrl-C / SIGINT (Windows:
289/// `CREATE_NEW_PROCESS_GROUP` + `DETACHED_PROCESS`; Unix: `setsid` puts the
290/// daemon in a new session so it's not in the parent's foreground group).
291///
292/// The NUL-stdio guarantee is enforced internally by the platform impls
293/// and is not configurable — a detached daemon needs sunk stdio to
294/// avoid crashing on later `println!`/`eprintln!` after the parent
295/// closes its handles.
296pub fn spawn_daemon(command: &mut Command) -> std::io::Result<DaemonChild> {
297    spawn_daemon_with_env_policy(command, EnvironmentPolicy::Auto)
298}
299
300/// Like [`spawn_daemon`] but with explicit control over whether the
301/// daemon's inherited env is passed through to the child.
302///
303/// `clear_env = false` uses [`EnvironmentPolicy::Auto`], matching
304/// [`spawn_daemon`].
305///
306/// `clear_env = true`: child sees ONLY the explicit `command.env(...)`
307/// entries. Mirrors `command.env_clear()` semantics for callers using
308/// the manual `CreateProcessW` path (Rust stdlib's `env_clear` flag
309/// isn't observable through `Command::get_envs`, so our sanitized
310/// spawn machinery can't otherwise honour it).
311pub fn spawn_daemon_with_clear_env(
312    command: &mut Command,
313    clear_env: bool,
314) -> std::io::Result<DaemonChild> {
315    let policy = if clear_env {
316        EnvironmentPolicy::Clear
317    } else {
318        EnvironmentPolicy::Auto
319    };
320    spawn_daemon_with_env_policy(command, policy)
321}
322
323/// Spawn a detached daemon using an explicit environment policy.
324pub fn spawn_daemon_with_env_policy(
325    command: &mut Command,
326    policy: EnvironmentPolicy,
327) -> std::io::Result<DaemonChild> {
328    let policy = policy.resolve(SpawnLifetime::Daemon);
329    #[cfg(windows)]
330    {
331        imp::spawn_daemon(command, policy)
332    }
333    #[cfg(unix)]
334    {
335        unix_impl::spawn_daemon(command, policy)
336    }
337}
338
339/// Spawn `command` as a contained child with caller-controlled stdio.
340/// Sanitized handles, CREATE_NO_WINDOW. Child dies when the returned
341/// [`SpawnedChild`] is dropped.
342pub fn spawn(command: &mut Command, stdio: SpawnStdio<'_>) -> std::io::Result<SpawnedChild> {
343    spawn_with_env_policy(command, stdio, EnvironmentPolicy::Auto)
344}
345
346/// Spawn a contained child using an explicit environment policy.
347pub fn spawn_with_env_policy(
348    command: &mut Command,
349    stdio: SpawnStdio<'_>,
350    policy: EnvironmentPolicy,
351) -> std::io::Result<SpawnedChild> {
352    let policy = policy.resolve(SpawnLifetime::Contained);
353    #[cfg(windows)]
354    {
355        imp::spawn(command, stdio, policy)
356    }
357    #[cfg(unix)]
358    {
359        unix_impl::spawn(command, stdio, policy)
360    }
361}
362
363#[cfg(unix)]
364fn unix_exit_code(status: std::process::ExitStatus) -> i32 {
365    use std::os::unix::process::ExitStatusExt;
366    status
367        .code()
368        .unwrap_or_else(|| -status.signal().unwrap_or(1))
369}
370
371// ── Windows implementation ──────────────────────────────────────────────────
372
373#[cfg(windows)]
374#[path = "spawn_imp_windows.rs"]
375mod imp;
376
377#[cfg(unix)]
378#[path = "spawn_imp_unix.rs"]
379mod unix_impl;
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn spawn_stdio_default_has_sane_values() {
386        let s = SpawnStdio::default();
387        assert!(matches!(s.stdin, StdioSource::Null));
388        assert!(matches!(s.stdout, StdioSource::Parent));
389        assert!(matches!(s.stderr, StdioSource::Parent));
390        assert_eq!(s.drain_timeout, Some(Duration::from_secs(2)));
391        // No console window by default — opt-in only.
392        assert!(!s.show_console);
393    }
394
395    #[test]
396    fn auto_environment_policy_depends_on_lifetime() {
397        assert_eq!(
398            EnvironmentPolicy::Auto.resolve(SpawnLifetime::Contained),
399            EnvironmentPolicy::Inherit
400        );
401        assert_eq!(
402            EnvironmentPolicy::Auto.resolve(SpawnLifetime::Daemon),
403            EnvironmentPolicy::UserBaseline
404        );
405    }
406
407    #[test]
408    fn explicit_environment_policy_is_not_rewritten() {
409        for policy in [
410            EnvironmentPolicy::Inherit,
411            EnvironmentPolicy::UserBaseline,
412            EnvironmentPolicy::Clear,
413        ] {
414            assert_eq!(policy.resolve(SpawnLifetime::Contained), policy);
415            assert_eq!(policy.resolve(SpawnLifetime::Daemon), policy);
416        }
417    }
418}