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