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 mutations added through [`Command::env`], [`Command::envs`], or
40/// [`Command::env_remove`] are applied after the selected base and therefore
41/// always win.
42#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
43pub enum EnvironmentPolicy {
44 /// Choose from the process lifetime: contained subprocesses inherit,
45 /// while detached daemons start from the logged-in user's baseline.
46 #[default]
47 Auto,
48 /// Inherit the spawning process's environment.
49 Inherit,
50 /// Start from the logged-in user's machine + user environment, discarding
51 /// the spawning process's ambient environment except for the documented
52 /// Unix locale, time-zone, and temporary-directory allowlist.
53 ///
54 /// Windows implements this with `CreateEnvironmentBlock`. Unix
55 /// reconstructs a clean login environment from the user's identity
56 /// (`getpwuid_r` → `USER`/`LOGNAME`/`HOME`/`SHELL`, platform default
57 /// `PATH`, carried-over locale/`TZ`/`TMPDIR`), falling back to inheritance
58 /// only when the passwd entry cannot be resolved.
59 ///
60 /// Consumers that need values such as `CARGO_HOME`, `RUSTUP_HOME`,
61 /// `SOLDR_*`, credentials, or runner-specific paths must pass them
62 /// explicitly on the [`Command`].
63 UserBaseline,
64 /// Start from an empty environment.
65 Clear,
66}
67
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub(crate) enum SpawnLifetime {
70 Contained,
71 Daemon,
72}
73
74impl EnvironmentPolicy {
75 pub(crate) fn resolve(self, lifetime: SpawnLifetime) -> Self {
76 match (self, lifetime) {
77 (Self::Auto, SpawnLifetime::Contained) => Self::Inherit,
78 (Self::Auto, SpawnLifetime::Daemon) => Self::UserBaseline,
79 (explicit, _) => explicit,
80 }
81 }
82}
83
84// ── Public API ──────────────────────────────────────────────────────────────
85
86/// Caller-supplied stdio bindings for [`spawn`].
87///
88/// Each of `stdin`, `stdout`, `stderr` is independently a [`StdioSource`].
89/// `drain_timeout` bounds the post-mortem wait the watcher thread applies
90/// before force-closing any wrapper-held pipe ends so the parent observes
91/// EOF after the child exits. `None` means the wrapper never auto-closes;
92/// the parent is responsible for closing the pipes when it's done reading.
93///
94/// `show_console` (Windows-only effect) controls whether the child gets a
95/// console window. Default is `false` — `CREATE_NO_WINDOW` is set, so the
96/// child has no console regardless of how the parent was launched. Set this
97/// to `true` only when you actually want the child to inherit / allocate a
98/// console (interactive subprocess that should be visible to the user).
99pub struct SpawnStdio<'a> {
100 /// Source connected to the child's standard input.
101 pub stdin: StdioSource<'a>,
102 /// Source connected to the child's standard output.
103 pub stdout: StdioSource<'a>,
104 /// Source connected to the child's standard error.
105 pub stderr: StdioSource<'a>,
106 /// Maximum time the watcher waits before closing wrapper-held pipe ends.
107 pub drain_timeout: Option<Duration>,
108 /// Whether Windows children may inherit or allocate a visible console.
109 pub show_console: bool,
110}
111
112impl Default for SpawnStdio<'_> {
113 fn default() -> Self {
114 Self {
115 stdin: StdioSource::Null,
116 stdout: StdioSource::Parent,
117 stderr: StdioSource::Parent,
118 drain_timeout: Some(Duration::from_secs(2)),
119 show_console: false,
120 }
121 }
122}
123
124/// Per-slot source describing what the child should inherit for one of
125/// stdin / stdout / stderr.
126pub enum StdioSource<'a> {
127 /// Connect this slot to the platform null device (`NUL` / `/dev/null`).
128 Null,
129 /// Inherit the parent's corresponding standard handle. The kernel
130 /// receives a fresh inheritable duplicate; the parent's original slot
131 /// is untouched.
132 Parent,
133 /// Bind this slot to a caller-owned OS handle. The wrapper duplicates
134 /// the handle into an inheritable copy for the child; the caller
135 /// retains its own handle and is responsible for closing it.
136 #[cfg(windows)]
137 Handle(BorrowedHandle<'a>),
138 /// Bind this slot to a caller-owned file descriptor. Equivalent to
139 /// `StdioSource::Handle` on Unix.
140 #[cfg(unix)]
141 Fd(BorrowedFd<'a>),
142 /// Create a fresh anonymous pipe. The child gets one end; the parent
143 /// gets the other via [`SpawnedChild`]'s `stdin` / `stdout` / `stderr`
144 /// fields.
145 Pipe,
146 #[doc(hidden)]
147 _Phantom(std::marker::PhantomData<&'a ()>),
148}
149
150// _Phantom is uninhabitable from outside: PhantomData<&'a ()> is a private
151// constructor in practice (the variant is doc(hidden) and not constructed
152// anywhere in this crate). It's only here so the `'a` lifetime is always
153// used regardless of which cfg branch is active.
154
155/// Handle to a detached daemon spawned via [`spawn_daemon`].
156///
157/// The daemon child always has stdin/stdout/stderr connected to the
158/// platform null device (`NUL` on Windows, `/dev/null` on Unix) — a
159/// detached process with inherited stdio is the classic crash-on-first-
160/// `println!` failure mode after the parent closes its end, so the
161/// daemon-spawn path forecloses that by construction. Dropping
162/// `DaemonChild` does NOT terminate the daemon; it only closes the OS
163/// handle the wrapper held. Call [`DaemonChild::kill`] to terminate.
164pub struct DaemonChild {
165 pid: u32,
166 #[cfg(windows)]
167 handle: imp::OwnedHandle,
168 #[cfg(unix)]
169 child: std::process::Child,
170}
171
172impl DaemonChild {
173 /// Process ID.
174 pub fn id(&self) -> u32 {
175 self.pid
176 }
177
178 /// Forcibly terminate the child. Best-effort.
179 pub fn kill(&mut self) -> std::io::Result<()> {
180 #[cfg(windows)]
181 {
182 imp::terminate(&self.handle)
183 }
184 #[cfg(unix)]
185 {
186 self.child.kill()
187 }
188 }
189
190 /// Block until the child exits and return its exit code.
191 pub fn wait(&mut self) -> std::io::Result<i32> {
192 #[cfg(windows)]
193 {
194 imp::wait(&self.handle)
195 }
196 #[cfg(unix)]
197 {
198 let status = self.child.wait()?;
199 Ok(unix_exit_code(status))
200 }
201 }
202
203 /// Non-blocking variant of [`Self::wait`].
204 pub fn try_wait(&mut self) -> std::io::Result<Option<i32>> {
205 #[cfg(windows)]
206 {
207 imp::try_wait(&self.handle)
208 }
209 #[cfg(unix)]
210 {
211 Ok(self.child.try_wait()?.map(unix_exit_code))
212 }
213 }
214}
215
216/// Handle to a contained child spawned via [`spawn`].
217///
218/// On Drop, `SpawnedChild` synchronously kills the child:
219/// * Windows: closes the Job Object handle; `KILL_ON_JOB_CLOSE` causes the
220/// kernel to terminate every process in the job (the child and its
221/// descendants).
222/// * Unix: `killpg(pgid, SIGKILL)` and `waitpid` to reap.
223///
224/// The optional `stdin` / `stdout` / `stderr` fields are present when the
225/// corresponding [`StdioSource`] was [`StdioSource::Pipe`]; otherwise they
226/// are `None`.
227pub struct SpawnedChild {
228 /// Parent-side pipe for writing to child stdin when requested.
229 pub stdin: Option<std::process::ChildStdin>,
230 /// Parent-side pipe for reading child stdout when requested.
231 pub stdout: Option<std::process::ChildStdout>,
232 /// Parent-side pipe for reading child stderr when requested.
233 pub stderr: Option<std::process::ChildStderr>,
234 pid: u32,
235 #[cfg(windows)]
236 inner: imp::SpawnedInner,
237 #[cfg(unix)]
238 inner: unix_impl::SpawnedInner,
239}
240
241impl SpawnedChild {
242 /// Process ID of the spawned child.
243 pub fn id(&self) -> u32 {
244 self.pid
245 }
246
247 /// Forcibly terminate the child. Best-effort.
248 pub fn kill(&mut self) -> std::io::Result<()> {
249 #[cfg(windows)]
250 {
251 self.inner.kill()
252 }
253 #[cfg(unix)]
254 {
255 self.inner.kill()
256 }
257 }
258
259 /// Block until the child exits and return its exit code.
260 pub fn wait(&mut self) -> std::io::Result<i32> {
261 #[cfg(windows)]
262 {
263 self.inner.wait()
264 }
265 #[cfg(unix)]
266 {
267 self.inner.wait()
268 }
269 }
270
271 /// Non-blocking variant of [`Self::wait`].
272 pub fn try_wait(&mut self) -> std::io::Result<Option<i32>> {
273 #[cfg(windows)]
274 {
275 self.inner.try_wait()
276 }
277 #[cfg(unix)]
278 {
279 self.inner.try_wait()
280 }
281 }
282}
283
284impl Drop for SpawnedChild {
285 fn drop(&mut self) {
286 #[cfg(windows)]
287 {
288 self.inner.shutdown();
289 }
290 #[cfg(unix)]
291 {
292 self.inner.shutdown();
293 }
294 }
295}
296
297/// Set on every child spawned through the daemon path, so a process can be
298/// recognized as a *declared daemon* rather than inferred to be one.
299///
300/// # Why a positive marker
301///
302/// Reapers previously had to infer daemon-ness from the **absence** of
303/// [`crate::ORIGINATOR_ENV_VAR`], which `spawn_daemon` strips. But absence is
304/// overloaded: it means both "this process deliberately detached itself" and
305/// "something in the chain clobbered the environment" — and those are
306/// byte-identical at the observation point, so no amount of process-lineage
307/// tracking can separate them. See zackees/clud#522, where an
308/// ancestry-fallback proposal and a daemon exemption read the same signal and
309/// drew opposite conclusions.
310///
311/// A positive declaration removes the ambiguity: only a process that actually
312/// went through the daemon path carries this.
313///
314/// # Caveat
315///
316/// This is still an environment variable, so a chain that strips
317/// `RUNNING_PROCESS_ORIGINATOR` strips this too. It narrows the ambiguous case
318/// rather than eliminating it; a durable answer would need the daemon's
319/// supervisor to register the PID somewhere the reaper can read.
320///
321/// Distinct from `RUNNING_PROCESS_DAEMON_SCOPE`, which names a broker scope
322/// and is unrelated.
323pub const DAEMON_MARKER_ENV_VAR: &str = "RUNNING_PROCESS_IS_DAEMON";
324
325/// Spawn `command` as a detached daemon. NUL stdio, sanitized handles,
326/// no console window, ignores parent's Ctrl-C / SIGINT (Windows:
327/// `CREATE_NEW_PROCESS_GROUP` + `DETACHED_PROCESS`; Unix: `setsid` puts the
328/// daemon in a new session so it's not in the parent's foreground group).
329///
330/// The NUL-stdio guarantee is enforced internally by the platform impls
331/// and is not configurable — a detached daemon needs sunk stdio to
332/// avoid crashing on later `println!`/`eprintln!` after the parent
333/// closes its handles.
334pub fn spawn_daemon(command: &mut Command) -> std::io::Result<DaemonChild> {
335 spawn_daemon_with_env_policy(command, EnvironmentPolicy::Auto)
336}
337
338/// Like [`spawn_daemon`] but with explicit control over whether the
339/// daemon's inherited env is passed through to the child.
340///
341/// `clear_env = false` uses [`EnvironmentPolicy::Auto`], matching
342/// [`spawn_daemon`].
343///
344/// `clear_env = true`: child sees ONLY the explicit `command.env(...)`
345/// entries. Mirrors `command.env_clear()` semantics for callers using
346/// the manual `CreateProcessW` path (Rust stdlib's `env_clear` flag
347/// isn't observable through `Command::get_envs`, so our sanitized
348/// spawn machinery can't otherwise honour it).
349pub fn spawn_daemon_with_clear_env(
350 command: &mut Command,
351 clear_env: bool,
352) -> std::io::Result<DaemonChild> {
353 let policy = if clear_env {
354 EnvironmentPolicy::Clear
355 } else {
356 EnvironmentPolicy::Auto
357 };
358 spawn_daemon_with_env_policy(command, policy)
359}
360
361/// Spawn a detached daemon using an explicit environment policy.
362///
363/// [`EnvironmentPolicy::Auto`] resolves to
364/// [`EnvironmentPolicy::UserBaseline`] for daemons, excluding unlisted
365/// ambient variables. Use [`EnvironmentPolicy::Inherit`] as the explicit
366/// escape hatch for trusted callers that require the full parent environment.
367/// In every mode, explicit command environment additions, overrides, and
368/// removals are applied last.
369pub fn spawn_daemon_with_env_policy(
370 command: &mut Command,
371 policy: EnvironmentPolicy,
372) -> std::io::Result<DaemonChild> {
373 spawn_daemon_inner(command, policy, false)
374}
375
376/// Like [`spawn_daemon`], but the child also **breaks away from any Job
377/// Object the spawner belongs to** (Windows; a no-op elsewhere).
378///
379/// Use this for a daemon that must outlive the process tree that happened to
380/// start it — a build cache server, a language server, anything discovered
381/// and reused by later, unrelated invocations.
382///
383/// # Why this is separate from [`spawn_daemon`]
384///
385/// "Detached lifetime" and "escapes my caller's containment" are different
386/// properties, and callers genuinely want them independently. Job Object
387/// membership is inherited by every descendant at any depth, and jobs created
388/// by this crate carry `KILL_ON_JOB_CLOSE` — so without breakaway the kernel
389/// terminates such a daemon the moment the spawner's job handle drops, no
390/// matter how detached the daemon made itself.
391///
392/// But making that unconditional breaks the opposite use: a child spawned as
393/// a daemon purely to obtain a sanitized handle list must stay inside the
394/// caller's job. `testbins/src/bin/spawner.rs` does exactly this, and
395/// `containment_test::test_contained_group_kills_grandchildren` fails if its
396/// sleepers escape.
397///
398/// # Refusal is not silent
399///
400/// `CREATE_BREAKAWAY_FROM_JOB` is *refused*, not ignored, when the spawner
401/// sits inside a job that lacks `JOB_OBJECT_LIMIT_BREAKAWAY_OK`:
402/// `CreateProcessW` fails with `ERROR_ACCESS_DENIED`. Outer jobs we do not
403/// control are common (CI runners, container supervisors, debuggers), so the
404/// spawn retries once with the flag cleared — a daemon that stays contained
405/// beats a daemon that fails to start.
406pub fn spawn_daemon_breaking_away_from_job(command: &mut Command) -> std::io::Result<DaemonChild> {
407 spawn_daemon_inner(command, EnvironmentPolicy::Auto, true)
408}
409
410/// [`spawn_daemon_breaking_away_from_job`] with an explicit env policy.
411pub fn spawn_daemon_breaking_away_with_env_policy(
412 command: &mut Command,
413 policy: EnvironmentPolicy,
414) -> std::io::Result<DaemonChild> {
415 spawn_daemon_inner(command, policy, true)
416}
417
418/// Apply the daemon self-declaration to `command`. Split out from
419/// [`spawn_daemon_inner`] so the policy is unit-testable without spawning a
420/// real detached process.
421pub(crate) fn mark_as_daemon(command: &mut Command) {
422 command.env(DAEMON_MARKER_ENV_VAR, "1");
423}
424
425fn spawn_daemon_inner(
426 command: &mut Command,
427 policy: EnvironmentPolicy,
428 breakaway: bool,
429) -> std::io::Result<DaemonChild> {
430 // Every daemon-spawn variant funnels through here, so this is the one
431 // place that can mark them all — including the free functions consumers
432 // like zccache call directly.
433 mark_as_daemon(command);
434 let policy = policy.resolve(SpawnLifetime::Daemon);
435 #[cfg(windows)]
436 {
437 imp::spawn_daemon(command, policy, breakaway)
438 }
439 #[cfg(unix)]
440 {
441 // Unix has no Job Object; `setsid` already detaches the daemon from
442 // the parent's session and process group, so breakaway is moot.
443 let _ = breakaway;
444 unix_impl::spawn_daemon(command, policy)
445 }
446}
447
448/// Spawn `command` as a contained child with caller-controlled stdio.
449/// Sanitized handles, CREATE_NO_WINDOW. Child dies when the returned
450/// [`SpawnedChild`] is dropped.
451pub fn spawn(command: &mut Command, stdio: SpawnStdio<'_>) -> std::io::Result<SpawnedChild> {
452 spawn_with_env_policy(command, stdio, EnvironmentPolicy::Auto)
453}
454
455/// Spawn a contained child using an explicit environment policy.
456pub fn spawn_with_env_policy(
457 command: &mut Command,
458 stdio: SpawnStdio<'_>,
459 policy: EnvironmentPolicy,
460) -> std::io::Result<SpawnedChild> {
461 let policy = policy.resolve(SpawnLifetime::Contained);
462 #[cfg(windows)]
463 {
464 imp::spawn(command, stdio, policy)
465 }
466 #[cfg(unix)]
467 {
468 unix_impl::spawn(command, stdio, policy)
469 }
470}
471
472#[cfg(unix)]
473fn unix_exit_code(status: std::process::ExitStatus) -> i32 {
474 use std::os::unix::process::ExitStatusExt;
475 status
476 .code()
477 .unwrap_or_else(|| -status.signal().unwrap_or(1))
478}
479
480// ── Windows implementation ──────────────────────────────────────────────────
481
482#[cfg(windows)]
483#[path = "spawn_imp_windows.rs"]
484mod imp;
485
486#[cfg(unix)]
487#[path = "spawn_imp_unix.rs"]
488mod unix_impl;
489#[cfg(test)]
490mod tests {
491 use super::*;
492
493 #[test]
494 fn spawn_stdio_default_has_sane_values() {
495 let s = SpawnStdio::default();
496 assert!(matches!(s.stdin, StdioSource::Null));
497 assert!(matches!(s.stdout, StdioSource::Parent));
498 assert!(matches!(s.stderr, StdioSource::Parent));
499 assert_eq!(s.drain_timeout, Some(Duration::from_secs(2)));
500 // No console window by default — opt-in only.
501 assert!(!s.show_console);
502 }
503
504 #[test]
505 fn auto_environment_policy_depends_on_lifetime() {
506 assert_eq!(
507 EnvironmentPolicy::Auto.resolve(SpawnLifetime::Contained),
508 EnvironmentPolicy::Inherit
509 );
510 assert_eq!(
511 EnvironmentPolicy::Auto.resolve(SpawnLifetime::Daemon),
512 EnvironmentPolicy::UserBaseline
513 );
514 }
515
516 #[test]
517 fn explicit_environment_policy_is_not_rewritten() {
518 for policy in [
519 EnvironmentPolicy::Inherit,
520 EnvironmentPolicy::UserBaseline,
521 EnvironmentPolicy::Clear,
522 ] {
523 assert_eq!(policy.resolve(SpawnLifetime::Contained), policy);
524 assert_eq!(policy.resolve(SpawnLifetime::Daemon), policy);
525 }
526 }
527}