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}