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