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}