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