coreshift_core/spawn/mod.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/
4
5//! Process spawning and lifecycle management.
6//!
7//! This module exposes explicit Linux/Android process primitives. Callers must
8//! provide the exact argument vector and choose the spawn backend. Core does not
9//! infer shell/root behavior, select backends from platform properties, or
10//! silently switch between backends.
11
12use std::os::unix::io::RawFd;
13use std::time::{Duration, Instant};
14
15use crate::CoreError;
16use crate::error::syscall_ret;
17use crate::fd::Fd;
18use crate::io::DrainState;
19use crate::reactor::Reactor;
20use libc::{O_CLOEXEC, WEXITSTATUS, WIFEXITED, WIFSIGNALED, WTERMSIG, pid_t, pipe2, waitpid};
21use std::collections::HashSet;
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::sync::{Mutex, OnceLock};
24
25mod clone3;
26mod exec;
27mod fork;
28mod posix;
29
30use clone3::spawn_clone3_internal;
31use exec::ExecContext;
32use fork::{spawn_fork_internal, spawn_vfork_internal};
33use posix::spawn_posix_internal;
34
35unsafe extern "C" {
36 pub(crate) static mut environ: *mut *mut libc::c_char;
37}
38
39/// Raw syscall numbers the `libc` crate does not expose on every target
40/// (notably Android). `clone3` (435) and `pidfd_send_signal` (424) use the same
41/// number on every architecture that implements them.
42#[cfg(any(
43 target_arch = "x86_64",
44 target_arch = "aarch64",
45 target_arch = "arm",
46 target_arch = "riscv64",
47 target_arch = "loongarch64",
48 target_arch = "powerpc64",
49 target_arch = "s390x"
50))]
51const SYS_CLONE3: libc::c_long = 435;
52#[cfg(any(
53 target_arch = "x86_64",
54 target_arch = "aarch64",
55 target_arch = "arm",
56 target_arch = "riscv64",
57 target_arch = "loongarch64",
58 target_arch = "powerpc64",
59 target_arch = "s390x"
60))]
61const SYS_PIDFD_SEND_SIGNAL: libc::c_long = 424;
62
63/// `CLONE_PIDFD` flag for `clone3`: the kernel writes a pidfd for the child
64/// into the `pidfd` field of `clone_args`.
65const CLONE_PIDFD: u64 = 0x0000_1000;
66
67/// Upper bound on how long to keep polling for a reap after SIGKILL has been
68/// sent. A child stuck in uninterruptible sleep (D-state) cannot be reaped at
69/// all — SIGKILL stays pending until it leaves D-state — so after this window
70/// the wait loop gives up and returns the partial output instead of spinning
71/// forever. Mirrors the bounded reap wait in [`ManagedProcess`]'s `Drop`.
72const D_STATE_REAP_BOUND: Duration = Duration::from_millis(500);
73
74/// Orphaned children: processes this library spawned whose caller will never
75/// call `wait` (the `wait = false` path) or that the wait loop gave up on
76/// reaping (D-state / cancel-timeout give-up). A reaper thread `waitpid`s each
77/// registered pid so they do not accumulate as zombies — a long-lived daemon
78/// that detaches children would otherwise exhaust the pid space (finding 15).
79///
80/// Only *registered* pids are reaped. A global `waitpid(-1)` loop would race
81/// with callers explicitly waiting on other children of this process; targeting
82/// registered pids is safe because they are our own direct children — the pid
83/// cannot be recycled until we reap it.
84static ORPHANED: OnceLock<Mutex<HashSet<pid_t>>> = OnceLock::new();
85static REAPER_STARTED: AtomicBool = AtomicBool::new(false);
86
87/// Register `pid` as orphaned (nobody will `wait` on it) and ensure the
88/// background reaper is running. No-op if the pid is already registered.
89pub(super) fn orphan_child(pid: pid_t) {
90 ORPHANED
91 .get_or_init(|| Mutex::new(HashSet::new()))
92 .lock()
93 .unwrap()
94 .insert(pid);
95 start_reaper();
96}
97
98/// Spawn (once) the background reaper thread that reaps [`ORPHANED`] pids.
99fn start_reaper() {
100 if REAPER_STARTED.load(Ordering::SeqCst) {
101 return;
102 }
103 let r = REAPER_STARTED.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst);
104 if r.is_err() {
105 return;
106 }
107 std::thread::Builder::new()
108 .name("spawn-orphan-reaper".into())
109 .spawn(reap_orphaned)
110 .map_err(|_| REAPER_STARTED.store(false, Ordering::SeqCst))
111 .ok();
112}
113
114/// Reaper body: periodically `waitpid` (non-blocking) every orphaned pid and
115/// drop it from the set once it has been reaped (or is already gone, which can
116/// only mean it was reaped elsewhere — the pid was still registered).
117fn reap_orphaned() {
118 loop {
119 let pids: Vec<pid_t> = ORPHANED
120 .get_or_init(|| Mutex::new(HashSet::new()))
121 .lock()
122 .unwrap()
123 .iter()
124 .copied()
125 .collect();
126 let mut still_orphaned = Vec::new();
127 for pid in pids {
128 let mut status: libc::c_int = 0;
129 let r = unsafe { waitpid(pid, &mut status, libc::WNOHANG) };
130 if r == pid
131 || (r < 0 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ECHILD))
132 {
133 continue; // reaped or gone — drop from the set
134 }
135 still_orphaned.push(pid);
136 }
137 if !still_orphaned.is_empty() {
138 if let Some(set) = ORPHANED.get() {
139 if let Ok(mut guard) = set.lock() {
140 for pid in still_orphaned {
141 guard.insert(pid);
142 }
143 }
144 }
145 }
146 std::thread::sleep(Duration::from_millis(250));
147 }
148}
149
150/// Detach a pid from the orphan set (used when a previously-orphaned process
151/// turns out to be waitable again; currently unused by callers but keeps the
152/// registry honest).
153#[allow(dead_code)]
154fn deorphan_child(pid: pid_t) {
155 if let Some(set) = ORPHANED.get() {
156 if let Ok(mut guard) = set.lock() {
157 guard.remove(&pid);
158 }
159 }
160}
161
162/// Policy for handling process cancellation or timeouts.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
164pub enum CancelPolicy {
165 /// Do nothing on cancellation; let the process run to completion.
166 #[default]
167 None,
168 /// Send SIGTERM, then SIGKILL after a grace period.
169 Graceful,
170 /// Send SIGKILL immediately.
171 Kill,
172}
173
174/// Process group and session configuration.
175#[derive(Debug, Clone, Copy, Default)]
176pub struct ProcessGroup {
177 /// Join an existing process group leader.
178 pub leader: Option<pid_t>,
179 /// Create a new session (`setsid`).
180 pub isolated: bool,
181}
182
183impl ProcessGroup {
184 /// Create a new process group configuration.
185 pub fn new(leader: Option<pid_t>, isolated: bool) -> Self {
186 Self { leader, isolated }
187 }
188}
189
190#[inline(always)]
191fn errno() -> i32 {
192 std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
193}
194
195/// Relocate `fd` to the lowest available descriptor `>= 3`, closing the
196/// original. Guards against `pipe2` handing back fds 0/1/2 when the daemon
197/// runs with stdio closed: a pipe on 0/1/2 would collide with the child's
198/// `dup2(…, 0/1/2)` setup (clobbering a still-needed end) and with the
199/// stdio-tracking in `close_child_fds_for_policy`.
200fn relocate_above_stdio(fd: RawFd, op: &'static str) -> Result<RawFd, CoreError> {
201 if fd >= 3 {
202 return Ok(fd);
203 }
204 let new = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 3) };
205 syscall_ret(new, op)?;
206 unsafe {
207 libc::close(fd);
208 }
209 Ok(new)
210}
211
212/// Creates a pipe with O_CLOEXEC, relocated above stdio. Both ends stay
213/// blocking; the parent-facing ends are flipped to O_NONBLOCK by
214/// [`DrainState`] after spawn so the child never inherits a non-blocking
215/// stdio (which would silently truncate child output on `EAGAIN`).
216/// Invariants: FDs returned are strictly >= 3 and will close automatically on drop.
217#[inline(always)]
218fn make_pipe() -> Result<(Fd, Fd), CoreError> {
219 let mut fds = [0; 2];
220 let r = unsafe { pipe2(fds.as_mut_ptr(), O_CLOEXEC) };
221 syscall_ret(r, "pipe2")?;
222 let r0 = match relocate_above_stdio(fds[0], "pipe2:relocate") {
223 Ok(fd) => fd,
224 Err(e) => {
225 // fds[0] is still open when its relocation fails; close to avoid
226 // leaking under fd pressure (EMFILE).
227 unsafe {
228 libc::close(fds[0]);
229 }
230 return Err(e);
231 }
232 };
233 let r1 = match relocate_above_stdio(fds[1], "pipe2:relocate") {
234 Ok(fd) => fd,
235 Err(e) => {
236 // fds[1] is still open (relocation failed), and r0 was relocated
237 // above — both would leak on this error path.
238 unsafe {
239 libc::close(r0);
240 libc::close(fds[1]);
241 }
242 return Err(e);
243 }
244 };
245 Ok((Fd::new(r0, "pipe2")?, Fd::new(r1, "pipe2")?))
246}
247
248fn make_cloexec_pipe() -> Result<(RawFd, RawFd), CoreError> {
249 let mut fds = [0; 2];
250 let r = unsafe { pipe2(fds.as_mut_ptr(), O_CLOEXEC) };
251 syscall_ret(r, "pipe2")?;
252 let r0 = match relocate_above_stdio(fds[0], "pipe2:relocate") {
253 Ok(fd) => fd,
254 Err(e) => {
255 unsafe {
256 libc::close(fds[0]);
257 }
258 return Err(e);
259 }
260 };
261 let r1 = match relocate_above_stdio(fds[1], "pipe2:relocate") {
262 Ok(fd) => fd,
263 Err(e) => {
264 unsafe {
265 libc::close(r0);
266 libc::close(fds[1]);
267 }
268 return Err(e);
269 }
270 };
271 Ok((r0, r1))
272}
273
274struct Pipes {
275 stdin_r: Option<Fd>,
276 stdin_w: Option<Fd>,
277 stdout_r: Option<Fd>,
278 stdout_w: Option<Fd>,
279 stderr_r: Option<Fd>,
280 stderr_w: Option<Fd>,
281}
282
283impl Pipes {
284 fn new(in_buf: Option<&[u8]>, out: bool, err: bool) -> Result<Self, CoreError> {
285 let (stdin_r, stdin_w) = if in_buf.is_some() {
286 let (r, w) = make_pipe()?;
287 (Some(r), Some(w))
288 } else {
289 (None, None)
290 };
291
292 let (stdout_r, stdout_w) = if out {
293 let (r, w) = make_pipe()?;
294 (Some(r), Some(w))
295 } else {
296 (None, None)
297 };
298
299 let (stderr_r, stderr_w) = if err {
300 let (r, w) = make_pipe()?;
301 (Some(r), Some(w))
302 } else {
303 (None, None)
304 };
305
306 Ok(Self {
307 stdin_r,
308 stdin_w,
309 stdout_r,
310 stdout_w,
311 stderr_r,
312 stderr_w,
313 })
314 }
315
316 #[inline(always)]
317 fn close_all(&mut self) {
318 self.stdin_r.take();
319 self.stdin_w.take();
320 self.stdout_r.take();
321 self.stdout_w.take();
322 self.stderr_r.take();
323 self.stderr_w.take();
324 }
325}
326
327/// Represents the termination status of a process.
328#[derive(Debug, PartialEq, Eq)]
329pub enum ExitStatus {
330 /// Process exited normally with the specified code.
331 Exited(i32),
332 /// Process was terminated by a signal.
333 Signaled(i32),
334}
335
336/// Explicit process spawning backend.
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
338pub enum SpawnBackend {
339 /// Force the use of `posix_spawn`.
340 PosixSpawn,
341 /// Force the use of `fork`/`exec`.
342 ///
343 /// The fork backend supports explicit [`SpawnFdPolicy`] handling before
344 /// `execve`.
345 Fork,
346 /// Force the use of `vfork`/`exec`.
347 ///
348 /// `vfork` shares the parent's address space with the child until it
349 /// `execve`s (or `_exit`s), so it avoids the page-table work of `fork`.
350 /// The child runs only async-signal-safe setup before `execve`, and the
351 /// calling thread is blocked until the child execs. Safe for the child
352 /// because the Linux `vfork` child inherits a *copy* of the descriptor
353 /// table, so [`SpawnFdPolicy`] handling works as with [`SpawnBackend::Fork`].
354 ///
355 /// Use only when the shared-address-space semantics are understood:
356 /// the child must never return from the spawn entry point, and a bug in the
357 /// child setup can corrupt the parent's memory.
358 Vfork,
359 /// Force the use of `clone3(2)`/`exec` (kernel 5.3+).
360 ///
361 /// `clone3` with process flags creates a child with copy-on-write memory
362 /// and a copied descriptor table, like [`SpawnBackend::Fork`], but lets the
363 /// caller control clone flags directly. Supported by the same child setup
364 /// as the fork backend. Returns `ENOSYS` on kernels without `clone3`.
365 Clone3,
366 /// Force the use of `clone3(2)` with `CLONE_PIDFD` + `exec` (kernel 5.3+).
367 ///
368 /// Identical to [`SpawnBackend::Clone3`], but the kernel additionally hands
369 /// the parent a pidfd for the child. The resulting [`Process`] carries that
370 /// pidfd: signaling uses `pidfd_send_signal` (immune to pid reuse), and
371 /// exit detection `poll`s the pidfd instead of polling `waitpid`. Returns
372 /// `ENOSYS` on kernels without `clone3`.
373 Clone3Pidfd,
374}
375
376/// Explicit file-descriptor inheritance policy for spawned children.
377#[derive(Debug, Clone, PartialEq, Eq, Default)]
378pub enum SpawnFdPolicy {
379 /// Inherit descriptors according to their existing `FD_CLOEXEC` flags.
380 #[default]
381 CloexecOnly,
382 /// For the fork backend, close every descriptor >= 3 before `execve`,
383 /// except Core-required pipe descriptors.
384 CloseFrom3,
385 /// For the fork backend, close every descriptor >= 3 before `execve`,
386 /// except Core-required pipe descriptors and the listed descriptors.
387 ///
388 /// Core does not close allowlisted descriptors, but their existing
389 /// `FD_CLOEXEC` state still applies. Callers that want an allowlisted
390 /// descriptor to survive `execve` must clear `FD_CLOEXEC` before spawning.
391 Allowlist(Vec<RawFd>),
392}
393
394#[inline(always)]
395fn decode_status(status: i32) -> ExitStatus {
396 if WIFEXITED(status) {
397 ExitStatus::Exited(WEXITSTATUS(status))
398 } else if WIFSIGNALED(status) {
399 ExitStatus::Signaled(WTERMSIG(status))
400 } else {
401 ExitStatus::Exited(-1)
402 }
403}
404
405/// A handle to a spawned process.
406///
407/// ### Fork Safety
408/// The process handle contains a PID. After a `fork`, the child process will
409/// have a copy of this PID, but it refers to the same original process.
410/// Calling `wait` or `kill` from the child may lead to confusing results
411/// if multiple processes are managing the same PID.
412///
413/// When the process was spawned by [`SpawnBackend::Clone3Pidfd`], the handle
414/// additionally owns the child's pidfd. Signaling then uses
415/// `pidfd_send_signal`, which cannot race with pid reuse, and exit detection
416/// `poll`s the pidfd. The pidfd is closed when the handle is dropped.
417pub struct Process {
418 pid: pid_t,
419 pidfd: Option<RawFd>,
420}
421
422impl Process {
423 /// Create a handle for an existing PID (no pidfd).
424 pub fn new(pid: pid_t) -> Self {
425 Self { pid, pidfd: None }
426 }
427
428 /// Create a handle for an existing PID that also owns its pidfd.
429 pub(crate) fn with_pidfd(pid: pid_t, pidfd: RawFd) -> Self {
430 Self {
431 pid,
432 pidfd: Some(pidfd),
433 }
434 }
435
436 /// Return the process ID.
437 pub fn pid(&self) -> pid_t {
438 self.pid
439 }
440
441 /// Return the pidfd owned by this handle, if any.
442 pub fn pidfd(&self) -> Option<RawFd> {
443 self.pidfd
444 }
445
446 /// Perform a non-blocking wait for process termination.
447 ///
448 /// When the handle owns a pidfd, the wait first `poll`s the pidfd (which
449 /// becomes readable exactly when the child exits) and then reaps with
450 /// `waitpid`, avoiding the `ECHILD`-race of polling `waitpid` directly.
451 ///
452 /// ### Errors
453 /// - `ECHILD`: The process does not exist or is not a child of the caller.
454 /// - `EINTR`: The call was interrupted by a signal (handled internally).
455 pub fn wait_step(&self) -> Result<Option<ExitStatus>, CoreError> {
456 if let Some(pidfd) = self.pidfd {
457 return wait_step_pidfd(pidfd, self.pid);
458 }
459 loop {
460 let mut status = 0;
461 let r = unsafe { waitpid(self.pid, &mut status, libc::WNOHANG) };
462 if r == 0 {
463 return Ok(None);
464 }
465 if r < 0 {
466 let e = errno();
467 if e == libc::EINTR {
468 continue;
469 }
470 return Err(CoreError::sys(e, "waitpid_step"));
471 }
472 return Ok(Some(decode_status(status)));
473 }
474 }
475
476 /// Block until the process terminates.
477 ///
478 /// ### Errors
479 /// - `ECHILD`: The process does not exist or is not a child of the caller.
480 pub fn wait_blocking(&self) -> Result<ExitStatus, CoreError> {
481 loop {
482 let mut status = 0;
483 let r = unsafe { waitpid(self.pid, &mut status, 0) };
484 if r < 0 {
485 let e = errno();
486 if e == libc::EINTR {
487 continue;
488 }
489 return Err(CoreError::sys(e, "waitpid_blocking"));
490 }
491 return Ok(decode_status(status));
492 }
493 }
494
495 /// Send a signal to the process.
496 ///
497 /// When the handle owns a pidfd, the signal is delivered with
498 /// `pidfd_send_signal`, which cannot target a recycled pid; on kernels
499 /// without it (`ENOSYS`, kernel < 5.1) it falls back to `kill`.
500 ///
501 /// ### Errors
502 /// - `EINVAL`: Invalid signal number, or a non-positive pid (pid `0`
503 /// would signal the caller's own process group). With a pidfd,
504 /// `pidfd_send_signal` returns `EINVAL` for an invalid signal and this
505 /// is reported, not downgraded to a `kill` fallback.
506 /// - `EPERM`: The caller does not have permission to send the signal.
507 /// - `ESRCH`: The process does not exist.
508 pub fn kill(&self, sig: i32) -> Result<(), CoreError> {
509 if let Some(pidfd) = self.pidfd {
510 let r = unsafe {
511 libc::syscall(
512 SYS_PIDFD_SEND_SIGNAL,
513 pidfd,
514 sig,
515 std::ptr::null_mut::<libc::siginfo_t>(),
516 0,
517 )
518 };
519 if r < 0 {
520 let e = errno();
521 if e == libc::ESRCH {
522 return Ok(());
523 }
524 // `pidfd_send_signal` returns EINVAL for an invalid signal
525 // number or an unsupported flag — falling back to `kill` on
526 // EINVAL would change semantics (e.g. signal 0 becomes an
527 // existence check). Only a kernel that lacks the syscall
528 // entirely (ENOSYS, pre-5.1) warrants the `kill` fallback.
529 if e != libc::ENOSYS {
530 return Err(CoreError::sys(e, "pidfd_send_signal"));
531 }
532 // Kernel lacks pidfd_send_signal; fall through to kill.
533 } else {
534 return Ok(());
535 }
536 }
537 if self.pid <= 0 {
538 return Err(CoreError::sys(libc::EINVAL, "kill: invalid pid"));
539 }
540 let r = unsafe { libc::kill(self.pid, sig) };
541 if r < 0 {
542 let e = errno();
543 if e == libc::ESRCH {
544 return Ok(());
545 }
546 syscall_ret(-1, "kill")?;
547 }
548 Ok(())
549 }
550
551 /// Signal the process group whose id equals [`Self::pid`] — valid only
552 /// when the process is its own group/session leader. For a child placed
553 /// into a custom leader's group use [`Self::kill_group`].
554 ///
555 /// ### Errors
556 /// Same as [`Self::kill`].
557 pub fn kill_pgroup(&self, sig: i32) -> Result<(), CoreError> {
558 self.kill_group(self.pid, sig)
559 }
560
561 /// Send a signal to an explicit process group.
562 ///
563 /// The pgid must be the child's actual group (its own pid after `setsid`,
564 /// or the configured leader's id after `setpgid`), never guessed from the
565 /// pid, and never `0` or negative — `kill(-0)` would signal the caller's
566 /// own process group.
567 ///
568 /// ### Errors
569 /// Same as [`Self::kill`], plus `EINVAL` for a non-positive pgid.
570 pub fn kill_group(&self, pgid: pid_t, sig: i32) -> Result<(), CoreError> {
571 if pgid <= 0 {
572 return Err(CoreError::sys(libc::EINVAL, "kill_group: invalid pgid"));
573 }
574 let r = unsafe { libc::kill(-pgid, sig) };
575 if r < 0 {
576 let e = errno();
577 if e == libc::ESRCH {
578 return Ok(());
579 }
580 syscall_ret(-1, "kill_group")?;
581 }
582 Ok(())
583 }
584}
585
586impl Drop for Process {
587 fn drop(&mut self) {
588 if let Some(pidfd) = self.pidfd.take() {
589 unsafe {
590 libc::close(pidfd);
591 }
592 }
593 }
594}
595
596/// Non-blocking exit wait using a pidfd: `poll(2)` on the pidfd becomes
597/// readable exactly when the child exits, and reaping still uses `waitpid`
598/// (our own child cannot be pid-recycled while it is unreaped). Returns
599/// `Ok(None)` while the child is running or was already reaped.
600fn wait_step_pidfd(pidfd: RawFd, pid: pid_t) -> Result<Option<ExitStatus>, CoreError> {
601 let mut pfd = libc::pollfd {
602 fd: pidfd,
603 events: libc::POLLIN,
604 revents: 0,
605 };
606 loop {
607 let r = unsafe { libc::poll(&mut pfd, 1, 0) };
608 if r < 0 {
609 let e = errno();
610 if e == libc::EINTR {
611 continue;
612 }
613 return Err(CoreError::sys(e, "poll(pidfd)"));
614 }
615 break;
616 }
617 if pfd.revents & libc::POLLIN == 0 {
618 return Ok(None);
619 }
620 loop {
621 let mut status = 0;
622 let r = unsafe { waitpid(pid, &mut status, libc::WNOHANG) };
623 if r == pid {
624 return Ok(Some(decode_status(status)));
625 }
626 if r < 0 {
627 let e = errno();
628 if e == libc::EINTR {
629 continue;
630 }
631 if e == libc::ECHILD {
632 // Reaped elsewhere; the pidfd stays readable.
633 return Ok(None);
634 }
635 return Err(CoreError::sys(e, "waitpid(pidfd step)"));
636 }
637 // r == 0: readiness raced with a concurrent reap; not running now.
638 return Ok(None);
639 }
640}
641
642/// Configuration options for spawning a new process.
643#[derive(Clone)]
644pub struct SpawnOptions {
645 ctx: ExecContext,
646 stdin: Option<Box<[u8]>>,
647 capture_stdout: bool,
648 capture_stderr: bool,
649 wait: bool,
650 pgroup: ProcessGroup,
651 session_containment: bool,
652 max_output: usize,
653 timeout_ms: Option<u32>,
654 kill_grace_ms: u32,
655 cancel: CancelPolicy,
656 backend: SpawnBackend,
657 fd_policy: SpawnFdPolicy,
658 early_exit: Option<fn(&[u8]) -> bool>,
659}
660
661impl SpawnOptions {
662 /// Create a new builder for process spawning.
663 pub fn builder(argv: Vec<String>, backend: SpawnBackend) -> SpawnOptionsBuilder {
664 SpawnOptionsBuilder::new(argv, backend)
665 }
666
667 /// Execute the process according to the options and block until completion.
668 pub fn run(self) -> Result<Output, CoreError> {
669 spawn(self)
670 }
671}
672
673/// Builder for [`SpawnOptions`].
674#[derive(Clone)]
675pub struct SpawnOptionsBuilder {
676 argv: Vec<String>,
677 env: Option<Vec<String>>,
678 cwd: Option<String>,
679 stdin: Option<Box<[u8]>>,
680 capture_stdout: bool,
681 capture_stderr: bool,
682 wait: bool,
683 pgroup: ProcessGroup,
684 session_containment: bool,
685 max_output: usize,
686 timeout_ms: Option<u32>,
687 kill_grace_ms: u32,
688 cancel: CancelPolicy,
689 backend: SpawnBackend,
690 fd_policy: SpawnFdPolicy,
691 early_exit: Option<fn(&[u8]) -> bool>,
692}
693
694impl SpawnOptionsBuilder {
695 /// Create a new builder with the specified argument vector.
696 pub fn new(argv: Vec<String>, backend: SpawnBackend) -> Self {
697 Self {
698 argv,
699 env: None,
700 cwd: None,
701 stdin: None,
702 capture_stdout: false,
703 capture_stderr: false,
704 wait: true,
705 pgroup: ProcessGroup::default(),
706 session_containment: false,
707 max_output: 1024 * 1024,
708 timeout_ms: None,
709 kill_grace_ms: 2000,
710 cancel: CancelPolicy::Kill,
711 backend,
712 fd_policy: SpawnFdPolicy::default(),
713 early_exit: None,
714 }
715 }
716
717 /// Set environment variables.
718 pub fn env(mut self, env: Vec<String>) -> Self {
719 self.env = Some(env);
720 self
721 }
722
723 /// Set the working directory.
724 pub fn cwd(mut self, cwd: String) -> Self {
725 self.cwd = Some(cwd);
726 self
727 }
728
729 /// Provide data to be written to the child's stdin.
730 pub fn stdin(mut self, data: impl Into<Box<[u8]>>) -> Self {
731 self.stdin = Some(data.into());
732 self
733 }
734
735 /// Enable stdout capture.
736 pub fn capture_stdout(mut self) -> Self {
737 self.capture_stdout = true;
738 self
739 }
740
741 /// Enable stderr capture.
742 pub fn capture_stderr(mut self) -> Self {
743 self.capture_stderr = true;
744 self
745 }
746
747 /// Set whether to wait for the process to terminate (default: true).
748 pub fn wait(mut self, wait: bool) -> Self {
749 self.wait = wait;
750 self
751 }
752
753 /// Set process group and isolation policy.
754 pub fn pgroup(mut self, pgroup: ProcessGroup) -> Self {
755 self.pgroup = pgroup;
756 self
757 }
758
759 /// Contain the child inside the process group/session it is placed into.
760 ///
761 /// A seccomp filter installed in the child (after the daemon's own
762 /// `setsid`/`setpgid`, before `execve`) denies `setsid`, `setpgid`,
763 /// `setpgrp`, `unshare`, and `setns`. Because filters are inherited
764 /// across `fork` and `execve` and can only be tightened, never loosened,
765 /// the child and every descendant are locked into the group/session —
766 /// making `kill_group` (timeout/cancel deactivation) total even against a
767 /// hostile root child that tries to escape by daemonizing or changing its
768 /// process group. Requires an isolated process group
769 /// ([`ProcessGroup::new(None, true)`](ProcessGroup::new)); rejected on
770 /// [`SpawnBackend::PosixSpawn`](SpawnBackend::PosixSpawn), which has no
771 /// child setup step.
772 pub fn session_containment(mut self) -> Self {
773 self.session_containment = true;
774 self
775 }
776
777 /// Set the combined stdout+stderr output buffer size (default: 1MB).
778 ///
779 /// If captured output exceeds this limit, spawn drains the child pipes to
780 /// completion and returns `EOVERFLOW`.
781 pub fn max_output(mut self, max: usize) -> Self {
782 self.max_output = max;
783 self
784 }
785
786 /// Set the execution timeout in milliseconds.
787 pub fn timeout_ms(mut self, ms: u32) -> Self {
788 self.timeout_ms = Some(ms);
789 self
790 }
791
792 /// Set the grace period before SIGKILL (default: 2s).
793 pub fn kill_grace_ms(mut self, ms: u32) -> Self {
794 self.kill_grace_ms = ms;
795 self
796 }
797
798 /// Set the cancellation policy (default: Kill).
799 pub fn cancel(mut self, policy: CancelPolicy) -> Self {
800 self.cancel = policy;
801 self
802 }
803
804 /// Set the child file-descriptor inheritance policy.
805 pub fn fd_policy(mut self, policy: SpawnFdPolicy) -> Self {
806 self.fd_policy = policy;
807 self
808 }
809
810 /// Set an early exit callback.
811 pub fn early_exit(mut self, callback: fn(&[u8]) -> bool) -> Self {
812 self.early_exit = Some(callback);
813 self
814 }
815
816 /// Build the spawn options.
817 pub fn build(self) -> Result<SpawnOptions, CoreError> {
818 let ctx = ExecContext::new(self.argv, self.env, self.cwd)?;
819 Ok(SpawnOptions {
820 ctx,
821 stdin: self.stdin,
822 capture_stdout: self.capture_stdout,
823 capture_stderr: self.capture_stderr,
824 wait: self.wait,
825 pgroup: self.pgroup,
826 session_containment: self.session_containment,
827 max_output: self.max_output,
828 timeout_ms: self.timeout_ms,
829 kill_grace_ms: self.kill_grace_ms,
830 cancel: self.cancel,
831 backend: self.backend,
832 fd_policy: self.fd_policy,
833 early_exit: self.early_exit,
834 })
835 }
836}
837
838/// The result of a process execution.
839#[derive(Debug)]
840pub struct Output {
841 /// The PID of the finished process.
842 pub pid: pid_t,
843 /// Final exit status (None if `wait=false`).
844 pub status: Option<ExitStatus>,
845 /// Captured stdout buffer.
846 pub stdout: Vec<u8>,
847 /// Captured stderr buffer.
848 pub stderr: Vec<u8>,
849 /// Whether the process timed out.
850 pub timed_out: bool,
851 /// Whether stdout drain stopped because the early-exit callback matched.
852 pub stdout_early_exited: bool,
853}
854
855fn validate_backend(opts: &SpawnOptions) -> Result<(), CoreError> {
856 validate_fd_policy(&opts.fd_policy)?;
857 match opts.backend {
858 SpawnBackend::PosixSpawn => {
859 if opts.ctx.cwd.is_some() {
860 return Err(CoreError::sys(libc::EINVAL, "posix_spawn cwd unsupported"));
861 }
862 if opts.pgroup.isolated {
863 return Err(CoreError::sys(
864 libc::EINVAL,
865 "posix_spawn setsid unsupported",
866 ));
867 }
868 if opts.session_containment {
869 return Err(CoreError::sys(
870 libc::EINVAL,
871 "posix_spawn session containment unsupported",
872 ));
873 }
874 if opts.fd_policy != SpawnFdPolicy::CloexecOnly {
875 return Err(CoreError::sys(
876 libc::EINVAL,
877 "posix_spawn fd policy unsupported",
878 ));
879 }
880 Ok(())
881 }
882 SpawnBackend::Fork
883 | SpawnBackend::Vfork
884 | SpawnBackend::Clone3
885 | SpawnBackend::Clone3Pidfd => {
886 // After `setsid` the child is a session leader in a brand-new
887 // session; `setpgid(0, leader)` for a leader outside that session
888 // always fails with EPERM. A zero leader means "own pid" (the
889 // child's own group after setsid), which is valid. Applies to
890 // every exec-style backend: they all run the same child setup.
891 if opts.pgroup.isolated && opts.pgroup.leader.is_some_and(|l| l != 0) {
892 return Err(CoreError::sys(
893 libc::EINVAL,
894 "exec isolated + custom setpgid leader unsupported",
895 ));
896 }
897 // Session containment pins the child to the group/session the
898 // daemon placed it in; without isolation there is no such
899 // boundary to pin to.
900 if opts.session_containment && !opts.pgroup.isolated {
901 return Err(CoreError::sys(
902 libc::EINVAL,
903 "session containment requires an isolated process group",
904 ));
905 }
906 Ok(())
907 }
908 }
909}
910
911fn validate_fd_policy(policy: &SpawnFdPolicy) -> Result<(), CoreError> {
912 if let SpawnFdPolicy::Allowlist(fds) = policy {
913 let mut seen = Vec::with_capacity(fds.len());
914 for &fd in fds {
915 if fd < 0 {
916 return Err(CoreError::sys(libc::EINVAL, "spawn fd allowlist invalid"));
917 }
918 let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
919 if flags < 0 {
920 return Err(CoreError::sys(errno(), "spawn fd allowlist fcntl(F_GETFD)"));
921 }
922 if seen.contains(&fd) {
923 return Err(CoreError::sys(libc::EINVAL, "spawn fd allowlist duplicate"));
924 }
925 seen.push(fd);
926 }
927 }
928 Ok(())
929}
930
931/// Specialized drain state for process spawning.
932pub type SpawnDrain = DrainState<fn(&[u8]) -> bool>;
933
934/// A process that is currently running and being monitored.
935///
936/// ### Fork Safety
937/// This handle contains both a PID and owned file descriptors for process I/O.
938/// Upon `fork`, the descriptors are inherited. Standard `O_CLOEXEC` behavior
939/// applies after `exec`.
940pub struct RunningProcess {
941 /// Handle to the process.
942 pub process: Process,
943 drain: SpawnDrain,
944}
945
946/// Full process lifecycle driven by a caller-owned reactor.
947///
948/// `ManagedProcess` preserves the blocking [`spawn`] semantics while allowing
949/// an application reactor to stay responsive: Core owns timeout/cancellation
950/// escalation, process-group signaling, pipe draining, overflow reporting, and
951/// `waitpid` reaping; the caller only routes readiness events and polls on
952/// [`Self::next_deadline`].
953pub struct ManagedProcess {
954 running: Option<RunningProcess>,
955 pid: pid_t,
956 timeout_at: Option<Instant>,
957 kill_grace: Duration,
958 cancel: CancelPolicy,
959 pgroup: ProcessGroup,
960 cancel_at: Option<Instant>,
961 kill_state: KillState,
962 status: Option<ExitStatus>,
963 timed_out: bool,
964 kill_sent_at: Option<Instant>,
965 deadline_passed_at: Option<Instant>,
966}
967
968impl RunningProcess {
969 /// Register active stdio pipe descriptors with a reactor.
970 ///
971 /// Call this once after [`spawn_start`] when the process was started with
972 /// captured output or stdin data. The assigned tokens are kept internally
973 /// and later matched by [`Self::handle_reactor_event`].
974 pub fn register_with_reactor(&mut self, reactor: &mut Reactor) -> Result<(), CoreError> {
975 self.drain.register_with_reactor(reactor)
976 }
977
978 /// Apply one reactor readiness event to this process' stdio drain state.
979 ///
980 /// Events for unrelated tokens are ignored. Callers remain responsible for
981 /// waiting on [`Self::process`] and driving the reactor until [`Self::io_done`]
982 /// returns true.
983 pub fn handle_reactor_event(
984 &mut self,
985 reactor: &mut Reactor,
986 event: &crate::fd::Event,
987 ) -> Result<(), CoreError> {
988 if self.drain.stdout_matches(event.token) {
989 if event.readable || event.hangup {
990 self.drain.handle_stdout_ready(reactor)?;
991 } else if event.error {
992 self.drain.drop_stdout(reactor)?;
993 }
994 } else if self.drain.stderr_matches(event.token) {
995 if event.readable || event.hangup {
996 self.drain.handle_stderr_ready(reactor)?;
997 } else if event.error {
998 self.drain.drop_stderr(reactor)?;
999 }
1000 } else if self.drain.stdin_matches(event.token) {
1001 if event.writable {
1002 self.drain.handle_stdin_writable(reactor)?;
1003 } else if event.error || event.hangup {
1004 self.drain.drop_stdin(reactor)?;
1005 }
1006 }
1007 Ok(())
1008 }
1009
1010 /// Return whether all managed stdio pipes have been drained or closed.
1011 pub fn io_done(&self) -> bool {
1012 self.drain.is_done()
1013 }
1014
1015 /// Consume the running process handle and return captured stdout/stderr buffers.
1016 pub fn into_output_parts(self) -> (Vec<u8>, Vec<u8>) {
1017 self.drain.into_parts()
1018 }
1019}
1020
1021impl ManagedProcess {
1022 /// Return the child PID.
1023 ///
1024 /// The PID is captured at spawn time, so this remains available after the
1025 /// process has completed (unlike the running handle, which is consumed).
1026 pub fn pid(&self) -> pid_t {
1027 self.pid
1028 }
1029
1030 /// Register active child I/O descriptors with the caller's reactor.
1031 pub fn register_with_reactor(&mut self, reactor: &mut Reactor) -> Result<(), CoreError> {
1032 self.running
1033 .as_mut()
1034 .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1035 .register_with_reactor(reactor)
1036 }
1037
1038 /// Route one reactor event to the child's I/O drain state.
1039 pub fn handle_reactor_event(
1040 &mut self,
1041 reactor: &mut Reactor,
1042 event: &crate::fd::Event,
1043 ) -> Result<(), CoreError> {
1044 self.running
1045 .as_mut()
1046 .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1047 .handle_reactor_event(reactor, event)
1048 }
1049
1050 /// Request cancellation using the daemon-owned policy from
1051 /// [`SpawnOptionsBuilder::cancel`]. Repeated requests are idempotent.
1052 pub fn request_cancel(&mut self) {
1053 self.cancel_at.get_or_insert_with(Instant::now);
1054 }
1055
1056 /// Earliest time at which [`Self::poll_completion`] should run again.
1057 ///
1058 /// A bounded reap tick is returned while the child is live, and exact
1059 /// timeout / TERM-to-KILL deadlines take precedence. `None` means the
1060 /// completion was already consumed.
1061 pub fn next_deadline(&self) -> Option<Instant> {
1062 self.running.as_ref()?;
1063 let now = Instant::now();
1064 let mut next = now + Duration::from_millis(100);
1065 if !self.timed_out
1066 && let Some(timeout_at) = self.timeout_at
1067 && timeout_at < next
1068 {
1069 next = timeout_at;
1070 }
1071 if self.kill_state == KillState::TermSent
1072 && let Some(cancel_at) = self.cancel_at
1073 {
1074 let kill_at = cancel_at + self.kill_grace;
1075 if kill_at < next {
1076 next = kill_at;
1077 }
1078 }
1079 // D-state bound: wake the caller once the post-SIGKILL reap window has
1080 // elapsed so `poll_completion` can give up on an unreapable child.
1081 if let Some(sent_at) = self.kill_sent_at {
1082 let bail_at = sent_at + D_STATE_REAP_BOUND;
1083 if bail_at < next {
1084 next = bail_at;
1085 }
1086 }
1087 Some(next)
1088 }
1089
1090 /// Advance timeout/cancellation, reap state, and completion.
1091 ///
1092 /// Returns `Ok(None)` while work remains, the normal [`Output`] once the
1093 /// child is reaped and its pipes are drained, or `EOVERFLOW` when the
1094 /// configured combined output limit was exceeded on the fully-drained
1095 /// path. A forced-close (timeout/cancel with a wedged pipe) returns the
1096 /// partial output and the `timed_out` flag instead, matching blocking
1097 /// [`spawn`].
1098 pub fn poll_completion(&mut self, reactor: &mut Reactor) -> Result<Option<Output>, CoreError> {
1099 let now = Instant::now();
1100 if !self.timed_out
1101 && let Some(timeout_at) = self.timeout_at
1102 && now >= timeout_at
1103 {
1104 self.timed_out = true;
1105 self.cancel_at.get_or_insert(timeout_at);
1106 if self.cancel == CancelPolicy::None {
1107 // `CancelPolicy::None` never signals, so the D-state bound
1108 // below never fires; record when the deadline passed so the
1109 // give-up bound mirrors blocking `spawn` (finding 14).
1110 self.deadline_passed_at = Some(self.deadline_passed_at.unwrap_or(now));
1111 }
1112 }
1113
1114 self.advance_cancel(now)?;
1115
1116 let running = self
1117 .running
1118 .as_ref()
1119 .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
1120 if self.status.is_none() {
1121 self.status = running.process.wait_step()?;
1122 }
1123
1124 let io_done = running.io_done();
1125 if self.status.is_some() && (io_done || self.cancel_at.is_some()) {
1126 return self.finish(reactor, !io_done).map(Some);
1127 }
1128 // D-state: SIGKILL sent but the child still cannot be reaped. A child
1129 // stuck in uninterruptible sleep keeps the signal pending until it
1130 // leaves D-state; return the partial output instead of polling forever.
1131 if self.status.is_none()
1132 && self
1133 .kill_sent_at
1134 .is_some_and(|t| now.duration_since(t) >= D_STATE_REAP_BOUND)
1135 {
1136 return self.finish(reactor, true).map(Some);
1137 }
1138 // `CancelPolicy::None`: the deadline elapsed but nothing was ever
1139 // signaled, so a wedged child would poll forever. Give up with the
1140 // partial output after the same bound as the D-state path (finding 14).
1141 if self.status.is_none()
1142 && self.cancel == CancelPolicy::None
1143 && self
1144 .deadline_passed_at
1145 .is_some_and(|passed| now.duration_since(passed) >= D_STATE_REAP_BOUND)
1146 {
1147 return self.finish(reactor, true).map(Some);
1148 }
1149 Ok(None)
1150 }
1151
1152 fn advance_cancel(&mut self, now: Instant) -> Result<(), CoreError> {
1153 let Some(cancel_at) = self.cancel_at else {
1154 return Ok(());
1155 };
1156 // The child is already reaped — its pid may be recycled. Never signal.
1157 if self.status.is_some() {
1158 return Ok(());
1159 }
1160 let running = self
1161 .running
1162 .as_ref()
1163 .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
1164 let process = &running.process;
1165 let pid = process.pid();
1166 let pgid = effective_pgid(pid, self.pgroup);
1167 let target_is_group = self.pgroup.isolated || self.pgroup.leader.is_some();
1168 match self.kill_state {
1169 KillState::None => match self.cancel {
1170 CancelPolicy::None => {}
1171 CancelPolicy::Graceful => {
1172 let result = signal_process(process, target_is_group, pgid, libc::SIGTERM);
1173 self.kill_state = if result.is_ok() {
1174 KillState::TermSent
1175 } else {
1176 KillState::KillSent
1177 };
1178 if self.kill_state == KillState::KillSent {
1179 self.kill_sent_at = Some(now);
1180 }
1181 }
1182 CancelPolicy::Kill => {
1183 let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
1184 self.kill_state = KillState::KillSent;
1185 self.kill_sent_at = Some(now);
1186 }
1187 },
1188 KillState::TermSent if now >= cancel_at + self.kill_grace => {
1189 let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
1190 self.kill_state = KillState::KillSent;
1191 self.kill_sent_at = Some(now);
1192 }
1193 _ => {}
1194 }
1195 Ok(())
1196 }
1197
1198 fn finish(&mut self, reactor: &mut Reactor, force_close: bool) -> Result<Output, CoreError> {
1199 let mut running = self
1200 .running
1201 .take()
1202 .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
1203 for slot in running.drain.take_all_slots() {
1204 if force_close {
1205 let _ = reactor.del(&slot.fd);
1206 } else {
1207 reactor.del(&slot.fd)?;
1208 }
1209 }
1210 let pid = running.process.pid();
1211 let (stdout, stderr, output_limit_exceeded, stdout_early_exited) =
1212 running.drain.into_parts_with_state();
1213 // If the child was never reaped (D-state give-up / forced close with an
1214 // unreapable child), it will eventually exit and become a zombie — hand
1215 // it to the reaper so it does not accumulate in a long-lived daemon
1216 // (finding 15).
1217 if self.status.is_none() {
1218 orphan_child(pid);
1219 }
1220 // Mirror blocking `spawn`: overflow is reported only when the drain
1221 // completed naturally. On the forced-close path (timeout/cancel with a
1222 // wedged pipe) the caller gets the partial output and the timed-out
1223 // flag instead, matching the blocking N4 behavior.
1224 if output_limit_exceeded && !force_close {
1225 return Err(CoreError::sys(libc::EOVERFLOW, "spawn output limit"));
1226 }
1227 Ok(Output {
1228 pid,
1229 status: self.status.take(),
1230 stdout,
1231 stderr,
1232 timed_out: self.timed_out,
1233 stdout_early_exited,
1234 })
1235 }
1236}
1237
1238impl Drop for ManagedProcess {
1239 fn drop(&mut self) {
1240 let Some(running) = self.running.take() else {
1241 return;
1242 };
1243 // If the child was already reaped by `poll_completion`, the pid may
1244 // have been recycled — never signal it. The pipes are dropped with
1245 // `running`, so there is nothing left to clean up.
1246 if self.status.is_some() {
1247 return;
1248 }
1249 let process = &running.process;
1250 let pid = process.pid();
1251 // Respect CancelPolicy::None: "do nothing on cancellation" must not
1252 // kill the child on Drop either — the caller asked that cancellation
1253 // leave the child alone.
1254 if self.cancel != CancelPolicy::None {
1255 let pgid = effective_pgid(pid, self.pgroup);
1256 let target_is_group = self.pgroup.isolated || self.pgroup.leader.is_some();
1257 let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
1258 }
1259 // Bound the reap wait: SIGKILL terminates a runnable child
1260 // immediately, but a child stuck in uninterruptible sleep (D-state)
1261 // never dies. Poll with WNOHANG so `Drop` cannot wedge the caller's
1262 // reactor thread forever on a stuck child.
1263 let deadline = Instant::now() + Duration::from_millis(100);
1264 while Instant::now() < deadline {
1265 match process.wait_step() {
1266 Ok(Some(_)) => return,
1267 Ok(None) => std::thread::sleep(Duration::from_millis(5)),
1268 Err(_) => return,
1269 }
1270 }
1271 // Give-up: the child is unreapable right now (D-state) or still
1272 // running under `CancelPolicy::None`. Nobody will `waitpid` it now;
1273 // hand it to the reaper so it does not become a zombie on exit.
1274 orphan_child(pid);
1275 }
1276}
1277
1278fn effective_pgid(pid: pid_t, pgroup: ProcessGroup) -> pid_t {
1279 match pgroup.leader {
1280 Some(0) | None => pid,
1281 Some(leader) => leader,
1282 }
1283}
1284
1285fn signal_process(
1286 process: &Process,
1287 target_is_group: bool,
1288 pgid: pid_t,
1289 signal: i32,
1290) -> Result<(), CoreError> {
1291 if target_is_group {
1292 process.kill_group(pgid, signal)
1293 } else {
1294 process.kill(signal)
1295 }
1296}
1297
1298/// Start spawning a process and return a monitor handle.
1299///
1300/// This initializes the pipes and starts the process, but does not block. Use
1301/// [`RunningProcess::register_with_reactor`],
1302/// [`RunningProcess::handle_reactor_event`], [`RunningProcess::io_done`], and
1303/// [`RunningProcess::into_output_parts`] to drive captured stdio without
1304/// exposing internal drain state.
1305///
1306/// ### Errors
1307/// - `EACCES`: Permission denied for the executable.
1308/// - `EINVAL`: Invalid spawn options (e.g. background capture without wait).
1309/// - `EMFILE`: Process limit on open file descriptors hit.
1310/// - `ENOENT`: The executable was not found.
1311/// - `ENOMEM`: Insufficient memory to spawn the process.
1312pub fn spawn_start(opts: SpawnOptions) -> Result<RunningProcess, CoreError> {
1313 if !opts.wait && (opts.stdin.is_some() || opts.capture_stdout || opts.capture_stderr) {
1314 return Err(CoreError::sys(
1315 libc::EINVAL,
1316 "background I/O capture not supported (wait must be true)",
1317 ));
1318 }
1319
1320 validate_backend(&opts)?;
1321
1322 let (process, drain) = match opts.backend {
1323 SpawnBackend::PosixSpawn => spawn_posix_internal(opts)?,
1324 SpawnBackend::Fork => spawn_fork_internal(opts)?,
1325 SpawnBackend::Vfork => spawn_vfork_internal(opts)?,
1326 SpawnBackend::Clone3 => spawn_clone3_internal(opts, false)?,
1327 SpawnBackend::Clone3Pidfd => spawn_clone3_internal(opts, true)?,
1328 };
1329
1330 Ok(RunningProcess { process, drain })
1331}
1332
1333/// Start a process whose complete lifecycle is driven by a caller-owned
1334/// reactor.
1335pub fn spawn_managed(opts: SpawnOptions) -> Result<ManagedProcess, CoreError> {
1336 if !opts.wait {
1337 return Err(CoreError::sys(
1338 libc::EINVAL,
1339 "managed process requires wait=true",
1340 ));
1341 }
1342 let timeout_at = opts
1343 .timeout_ms
1344 .map(|ms| Instant::now() + Duration::from_millis(ms as u64));
1345 let kill_grace = Duration::from_millis(opts.kill_grace_ms as u64);
1346 let cancel = opts.cancel;
1347 let pgroup = opts.pgroup;
1348 let running = spawn_start(opts)?;
1349 let pid = running.process.pid();
1350 Ok(ManagedProcess {
1351 running: Some(running),
1352 pid,
1353 timeout_at,
1354 kill_grace,
1355 cancel,
1356 pgroup,
1357 cancel_at: None,
1358 kill_state: KillState::None,
1359 status: None,
1360 timed_out: false,
1361 kill_sent_at: None,
1362 deadline_passed_at: None,
1363 })
1364}
1365
1366/// Spawn a process and block until completion or timeout.
1367///
1368/// This is the primary high-level interface for process execution. It handles
1369/// the full lifecycle, including I/O multiplexing and signal management.
1370///
1371/// ### Errors
1372/// Returns the same errors as [`spawn_start`], plus any I/O or reactor errors
1373/// encountered during the wait loop.
1374pub fn spawn(opts: SpawnOptions) -> Result<Output, CoreError> {
1375 let wait = opts.wait;
1376 let timeout_ms = opts.timeout_ms;
1377 let kill_grace_ms = opts.kill_grace_ms;
1378 let cancel = opts.cancel;
1379 let pgroup = opts.pgroup;
1380
1381 let mut reactor = Reactor::new()?;
1382 let running = spawn_start(opts)?;
1383
1384 let pid = running.process.pid();
1385 let mut drain = running.drain;
1386
1387 if let Err(e) = drain.register_with_reactor(&mut reactor) {
1388 // The child is live but stdio registration failed; `running` is
1389 // dropped here so nobody will `waitpid` it. Hand it to the reaper.
1390 orphan_child(pid);
1391 return Err(e);
1392 }
1393
1394 if !wait {
1395 let (stdout, stderr) = drain.into_parts();
1396 // The caller will never `wait` on this pid — hand it to the reaper so
1397 // it does not become a zombie when it exits (finding 15).
1398 orphan_child(pid);
1399 return Ok(Output {
1400 pid,
1401 status: None,
1402 stdout,
1403 stderr,
1404 timed_out: false,
1405 stdout_early_exited: false,
1406 });
1407 }
1408
1409 wait_loop(
1410 running.process,
1411 drain,
1412 reactor,
1413 timeout_ms,
1414 kill_grace_ms,
1415 cancel,
1416 pgroup,
1417 )
1418}
1419
1420#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1421enum KillState {
1422 None,
1423 TermSent,
1424 KillSent,
1425}
1426
1427fn wait_loop(
1428 process: Process,
1429 mut drain: crate::io::DrainState<fn(&[u8]) -> bool>,
1430 mut reactor: Reactor,
1431 timeout_ms: Option<u32>,
1432 kill_grace_ms: u32,
1433 cancel: CancelPolicy,
1434 pgroup: ProcessGroup,
1435) -> Result<Output, CoreError> {
1436 let pid = process.pid();
1437 // M8: the child's effective pgid is the configured leader when one is set
1438 // (Setpgid is applied after Setsid in the child), else its own pid. A
1439 // timeout must signal `-pgid`; `kill(-pid)` would target a different
1440 // group for a custom leader and the child would never die.
1441 let pgid = effective_pgid(pid, pgroup);
1442 let mut status_raw = process.wait_step()?;
1443 let mut state = KillState::None;
1444 let mut timed_out = false;
1445 // D-state bound: recorded once SIGKILL has been sent. If the child still
1446 // refuses to die (or be reaped) after `D_STATE_REAP_BOUND`, give up and
1447 // return the partial output instead of spinning on a stuck child.
1448 let mut kill_sent_at: Option<Instant> = None;
1449 // Deadline give-up bound for `CancelPolicy::None`: no signal is ever sent,
1450 // so `kill_sent_at` stays unset and the D-state bound never fires. A wedged
1451 // child (pipe held open by a descendant, child unreaped) would otherwise
1452 // poll at 100 ms forever. Once the deadline has passed we give up after the
1453 // same bound, returning the partial output with `timed_out` set.
1454 let mut deadline_passed_at: Option<Instant> = None;
1455
1456 let start_time = std::time::Instant::now();
1457 let deadline = timeout_ms.map(|t| std::time::Duration::from_millis(t as u64));
1458
1459 loop {
1460 let mut poll_timeout = -1;
1461
1462 if let Some(dl) = deadline {
1463 let elapsed = start_time.elapsed();
1464 if elapsed >= dl {
1465 timed_out = true;
1466 deadline_passed_at = Some(deadline_passed_at.unwrap_or_else(Instant::now));
1467 let elapsed_over = (elapsed - dl).as_millis();
1468
1469 let target_is_group = pgroup.isolated || pgroup.leader.is_some();
1470
1471 // Only signal while the child is unreaped. Once waitpid has
1472 // reaped it the pid may already be recycled by the OS — killing
1473 // it would hit an unrelated process. The wedged-pipe path below
1474 // returns the partial output without sending any signal.
1475 if status_raw.is_none() {
1476 match state {
1477 KillState::None => {
1478 if cancel == CancelPolicy::Graceful {
1479 let r = if target_is_group {
1480 process.kill_group(pgid, libc::SIGTERM)
1481 } else {
1482 process.kill(libc::SIGTERM)
1483 };
1484 if r.is_err() {
1485 state = KillState::KillSent; // Process already gone
1486 kill_sent_at = Some(Instant::now());
1487 } else {
1488 state = KillState::TermSent;
1489 }
1490 } else if cancel == CancelPolicy::Kill {
1491 let _ = if target_is_group {
1492 process.kill_group(pgid, libc::SIGKILL)
1493 } else {
1494 process.kill(libc::SIGKILL)
1495 };
1496 state = KillState::KillSent;
1497 kill_sent_at = Some(Instant::now());
1498 } else {
1499 // CancelPolicy::None just times out without killing
1500 }
1501 }
1502 KillState::TermSent if elapsed_over > kill_grace_ms as u128 => {
1503 let _ = if target_is_group {
1504 process.kill_group(pgid, libc::SIGKILL)
1505 } else {
1506 process.kill(libc::SIGKILL)
1507 };
1508 state = KillState::KillSent;
1509 kill_sent_at = Some(Instant::now());
1510 }
1511 _ => {}
1512 }
1513 }
1514 poll_timeout = 100; // Poll frequently while waiting for kill to take effect
1515 } else {
1516 let remaining = dl - elapsed;
1517 poll_timeout = remaining.as_millis().min(i32::MAX as u128) as i32;
1518 }
1519 }
1520
1521 if status_raw.is_none()
1522 && let Some(s) = process.wait_step()?
1523 {
1524 status_raw = Some(s);
1525 }
1526
1527 if drain.is_done() {
1528 let s = if status_raw.is_some() {
1529 status_raw.take()
1530 } else if deadline.is_none() {
1531 // C1: all pipes drained but the child is still alive, and no
1532 // deadline is set → block until it exits (intended semantics).
1533 Some(process.wait_blocking()?)
1534 } else {
1535 // C1: pipes drained with a deadline set → never block here; fall
1536 // through to the bounded `reactor.wait` below so the deadline
1537 // logic at the top of the loop kills and reaps. A later
1538 // `wait_step` reaps the child and we return from this branch.
1539 None
1540 };
1541
1542 if let Some(s) = s {
1543 for slot in drain.take_all_slots() {
1544 reactor.del(&slot.fd)?;
1545 }
1546 let (stdout, stderr, output_limit_exceeded, stdout_early_exited) =
1547 drain.into_parts_with_state();
1548 if output_limit_exceeded {
1549 return Err(CoreError::sys(libc::EOVERFLOW, "spawn output limit"));
1550 }
1551 return Ok(Output {
1552 pid,
1553 status: Some(s),
1554 stdout,
1555 stderr,
1556 timed_out,
1557 stdout_early_exited,
1558 });
1559 }
1560 }
1561
1562 // N4: the deadline has elapsed and the child is reaped, but a wedged
1563 // pipe (a descendant inheriting the write end) keeps the drain from
1564 // closing. The absolute deadline is authoritative — return the partial
1565 // output instead of spinning forever.
1566 if timed_out && status_raw.is_some() {
1567 for slot in drain.take_all_slots() {
1568 let _ = reactor.del(&slot.fd);
1569 }
1570 let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
1571 drain.into_parts_with_state();
1572 return Ok(Output {
1573 pid,
1574 status: status_raw,
1575 stdout,
1576 stderr,
1577 timed_out: true,
1578 stdout_early_exited,
1579 });
1580 }
1581
1582 // D-state: SIGKILL has been sent but the child is still unreaped after
1583 // the bound. A child stuck in uninterruptible sleep keeps the signal
1584 // pending until it leaves D-state, so no further wait can succeed —
1585 // return the partial output rather than polling forever. The pid is
1586 // not signaled again (it may be recycled once it finally exits).
1587 if let Some(sent_at) = kill_sent_at
1588 && sent_at.elapsed() >= D_STATE_REAP_BOUND
1589 && status_raw.is_none()
1590 {
1591 for slot in drain.take_all_slots() {
1592 let _ = reactor.del(&slot.fd);
1593 }
1594 // The child is unreapable right now but will eventually leave
1595 // D-state and exit; nobody will wait on it after this give-up, so
1596 // hand it to the reaper (finding 15).
1597 orphan_child(pid);
1598 let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
1599 drain.into_parts_with_state();
1600 return Ok(Output {
1601 pid,
1602 status: None,
1603 stdout,
1604 stderr,
1605 timed_out: true,
1606 stdout_early_exited,
1607 });
1608 }
1609
1610 // `CancelPolicy::None`: the deadline elapsed but nothing was ever
1611 // signaled, so the child may stay wedged (pipe held by a descendant,
1612 // child unreaped) indefinitely. Give up with the partial output after
1613 // the same bound as the D-state path — otherwise this polls at 100 ms
1614 // forever (finding 14).
1615 if cancel == CancelPolicy::None
1616 && timed_out
1617 && status_raw.is_none()
1618 && deadline_passed_at.is_some_and(|passed| passed.elapsed() >= D_STATE_REAP_BOUND)
1619 {
1620 for slot in drain.take_all_slots() {
1621 let _ = reactor.del(&slot.fd);
1622 }
1623 // The child was never signaled and may still be running; nobody
1624 // will wait on it now — hand it to the reaper (finding 15).
1625 orphan_child(pid);
1626 let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
1627 drain.into_parts_with_state();
1628 return Ok(Output {
1629 pid,
1630 status: None,
1631 stdout,
1632 stderr,
1633 timed_out: true,
1634 stdout_early_exited,
1635 });
1636 }
1637
1638 let timeout = poll_timeout;
1639
1640 let mut events = Vec::new();
1641 let nevents = reactor.wait(&mut events, 64, timeout)?;
1642
1643 for ev in events.iter().take(nevents) {
1644 if drain.stdout_matches(ev.token) {
1645 if ev.readable || ev.hangup {
1646 drain.handle_stdout_ready(&mut reactor)?;
1647 } else if ev.error {
1648 drain.drop_stdout(&mut reactor)?;
1649 }
1650 } else if drain.stderr_matches(ev.token) {
1651 if ev.readable || ev.hangup {
1652 drain.handle_stderr_ready(&mut reactor)?;
1653 } else if ev.error {
1654 drain.drop_stderr(&mut reactor)?;
1655 }
1656 } else if drain.stdin_matches(ev.token) {
1657 if ev.writable {
1658 drain.handle_stdin_writable(&mut reactor)?;
1659 } else if ev.error || ev.hangup {
1660 drain.drop_stdin(&mut reactor)?;
1661 }
1662 }
1663 }
1664 }
1665}