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 max_output: usize,
652 timeout_ms: Option<u32>,
653 kill_grace_ms: u32,
654 cancel: CancelPolicy,
655 backend: SpawnBackend,
656 fd_policy: SpawnFdPolicy,
657 early_exit: Option<fn(&[u8]) -> bool>,
658}
659
660impl SpawnOptions {
661 /// Create a new builder for process spawning.
662 pub fn builder(argv: Vec<String>, backend: SpawnBackend) -> SpawnOptionsBuilder {
663 SpawnOptionsBuilder::new(argv, backend)
664 }
665
666 /// Execute the process according to the options and block until completion.
667 pub fn run(self) -> Result<Output, CoreError> {
668 spawn(self)
669 }
670}
671
672/// Builder for [`SpawnOptions`].
673#[derive(Clone)]
674pub struct SpawnOptionsBuilder {
675 argv: Vec<String>,
676 env: Option<Vec<String>>,
677 cwd: Option<String>,
678 stdin: Option<Box<[u8]>>,
679 capture_stdout: bool,
680 capture_stderr: bool,
681 wait: bool,
682 pgroup: ProcessGroup,
683 max_output: usize,
684 timeout_ms: Option<u32>,
685 kill_grace_ms: u32,
686 cancel: CancelPolicy,
687 backend: SpawnBackend,
688 fd_policy: SpawnFdPolicy,
689 early_exit: Option<fn(&[u8]) -> bool>,
690}
691
692impl SpawnOptionsBuilder {
693 /// Create a new builder with the specified argument vector.
694 pub fn new(argv: Vec<String>, backend: SpawnBackend) -> Self {
695 Self {
696 argv,
697 env: None,
698 cwd: None,
699 stdin: None,
700 capture_stdout: false,
701 capture_stderr: false,
702 wait: true,
703 pgroup: ProcessGroup::default(),
704 max_output: 1024 * 1024,
705 timeout_ms: None,
706 kill_grace_ms: 2000,
707 cancel: CancelPolicy::Kill,
708 backend,
709 fd_policy: SpawnFdPolicy::default(),
710 early_exit: None,
711 }
712 }
713
714 /// Set environment variables.
715 pub fn env(mut self, env: Vec<String>) -> Self {
716 self.env = Some(env);
717 self
718 }
719
720 /// Set the working directory.
721 pub fn cwd(mut self, cwd: String) -> Self {
722 self.cwd = Some(cwd);
723 self
724 }
725
726 /// Provide data to be written to the child's stdin.
727 pub fn stdin(mut self, data: impl Into<Box<[u8]>>) -> Self {
728 self.stdin = Some(data.into());
729 self
730 }
731
732 /// Enable stdout capture.
733 pub fn capture_stdout(mut self) -> Self {
734 self.capture_stdout = true;
735 self
736 }
737
738 /// Enable stderr capture.
739 pub fn capture_stderr(mut self) -> Self {
740 self.capture_stderr = true;
741 self
742 }
743
744 /// Set whether to wait for the process to terminate (default: true).
745 pub fn wait(mut self, wait: bool) -> Self {
746 self.wait = wait;
747 self
748 }
749
750 /// Set process group and isolation policy.
751 pub fn pgroup(mut self, pgroup: ProcessGroup) -> Self {
752 self.pgroup = pgroup;
753 self
754 }
755
756 /// Set the combined stdout+stderr output buffer size (default: 1MB).
757 ///
758 /// If captured output exceeds this limit, spawn drains the child pipes to
759 /// completion and returns `EOVERFLOW`.
760 pub fn max_output(mut self, max: usize) -> Self {
761 self.max_output = max;
762 self
763 }
764
765 /// Set the execution timeout in milliseconds.
766 pub fn timeout_ms(mut self, ms: u32) -> Self {
767 self.timeout_ms = Some(ms);
768 self
769 }
770
771 /// Set the grace period before SIGKILL (default: 2s).
772 pub fn kill_grace_ms(mut self, ms: u32) -> Self {
773 self.kill_grace_ms = ms;
774 self
775 }
776
777 /// Set the cancellation policy (default: Kill).
778 pub fn cancel(mut self, policy: CancelPolicy) -> Self {
779 self.cancel = policy;
780 self
781 }
782
783 /// Set the child file-descriptor inheritance policy.
784 pub fn fd_policy(mut self, policy: SpawnFdPolicy) -> Self {
785 self.fd_policy = policy;
786 self
787 }
788
789 /// Set an early exit callback.
790 pub fn early_exit(mut self, callback: fn(&[u8]) -> bool) -> Self {
791 self.early_exit = Some(callback);
792 self
793 }
794
795 /// Build the spawn options.
796 pub fn build(self) -> Result<SpawnOptions, CoreError> {
797 let ctx = ExecContext::new(self.argv, self.env, self.cwd)?;
798 Ok(SpawnOptions {
799 ctx,
800 stdin: self.stdin,
801 capture_stdout: self.capture_stdout,
802 capture_stderr: self.capture_stderr,
803 wait: self.wait,
804 pgroup: self.pgroup,
805 max_output: self.max_output,
806 timeout_ms: self.timeout_ms,
807 kill_grace_ms: self.kill_grace_ms,
808 cancel: self.cancel,
809 backend: self.backend,
810 fd_policy: self.fd_policy,
811 early_exit: self.early_exit,
812 })
813 }
814}
815
816/// The result of a process execution.
817#[derive(Debug)]
818pub struct Output {
819 /// The PID of the finished process.
820 pub pid: pid_t,
821 /// Final exit status (None if `wait=false`).
822 pub status: Option<ExitStatus>,
823 /// Captured stdout buffer.
824 pub stdout: Vec<u8>,
825 /// Captured stderr buffer.
826 pub stderr: Vec<u8>,
827 /// Whether the process timed out.
828 pub timed_out: bool,
829 /// Whether stdout drain stopped because the early-exit callback matched.
830 pub stdout_early_exited: bool,
831}
832
833fn validate_backend(opts: &SpawnOptions) -> Result<(), CoreError> {
834 validate_fd_policy(&opts.fd_policy)?;
835 match opts.backend {
836 SpawnBackend::PosixSpawn => {
837 if opts.ctx.cwd.is_some() {
838 return Err(CoreError::sys(libc::EINVAL, "posix_spawn cwd unsupported"));
839 }
840 if opts.pgroup.isolated {
841 return Err(CoreError::sys(
842 libc::EINVAL,
843 "posix_spawn setsid unsupported",
844 ));
845 }
846 if opts.fd_policy != SpawnFdPolicy::CloexecOnly {
847 return Err(CoreError::sys(
848 libc::EINVAL,
849 "posix_spawn fd policy unsupported",
850 ));
851 }
852 Ok(())
853 }
854 SpawnBackend::Fork
855 | SpawnBackend::Vfork
856 | SpawnBackend::Clone3
857 | SpawnBackend::Clone3Pidfd => {
858 // After `setsid` the child is a session leader in a brand-new
859 // session; `setpgid(0, leader)` for a leader outside that session
860 // always fails with EPERM. A zero leader means "own pid" (the
861 // child's own group after setsid), which is valid. Applies to
862 // every exec-style backend: they all run the same child setup.
863 if opts.pgroup.isolated && opts.pgroup.leader.is_some_and(|l| l != 0) {
864 return Err(CoreError::sys(
865 libc::EINVAL,
866 "exec isolated + custom setpgid leader unsupported",
867 ));
868 }
869 Ok(())
870 }
871 }
872}
873
874fn validate_fd_policy(policy: &SpawnFdPolicy) -> Result<(), CoreError> {
875 if let SpawnFdPolicy::Allowlist(fds) = policy {
876 let mut seen = Vec::with_capacity(fds.len());
877 for &fd in fds {
878 if fd < 0 {
879 return Err(CoreError::sys(libc::EINVAL, "spawn fd allowlist invalid"));
880 }
881 let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
882 if flags < 0 {
883 return Err(CoreError::sys(errno(), "spawn fd allowlist fcntl(F_GETFD)"));
884 }
885 if seen.contains(&fd) {
886 return Err(CoreError::sys(libc::EINVAL, "spawn fd allowlist duplicate"));
887 }
888 seen.push(fd);
889 }
890 }
891 Ok(())
892}
893
894/// Specialized drain state for process spawning.
895pub type SpawnDrain = DrainState<fn(&[u8]) -> bool>;
896
897/// A process that is currently running and being monitored.
898///
899/// ### Fork Safety
900/// This handle contains both a PID and owned file descriptors for process I/O.
901/// Upon `fork`, the descriptors are inherited. Standard `O_CLOEXEC` behavior
902/// applies after `exec`.
903pub struct RunningProcess {
904 /// Handle to the process.
905 pub process: Process,
906 drain: SpawnDrain,
907}
908
909/// Full process lifecycle driven by a caller-owned reactor.
910///
911/// `ManagedProcess` preserves the blocking [`spawn`] semantics while allowing
912/// an application reactor to stay responsive: Core owns timeout/cancellation
913/// escalation, process-group signaling, pipe draining, overflow reporting, and
914/// `waitpid` reaping; the caller only routes readiness events and polls on
915/// [`Self::next_deadline`].
916pub struct ManagedProcess {
917 running: Option<RunningProcess>,
918 pid: pid_t,
919 timeout_at: Option<Instant>,
920 kill_grace: Duration,
921 cancel: CancelPolicy,
922 pgroup: ProcessGroup,
923 cancel_at: Option<Instant>,
924 kill_state: KillState,
925 status: Option<ExitStatus>,
926 timed_out: bool,
927 kill_sent_at: Option<Instant>,
928 deadline_passed_at: Option<Instant>,
929}
930
931impl RunningProcess {
932 /// Register active stdio pipe descriptors with a reactor.
933 ///
934 /// Call this once after [`spawn_start`] when the process was started with
935 /// captured output or stdin data. The assigned tokens are kept internally
936 /// and later matched by [`Self::handle_reactor_event`].
937 pub fn register_with_reactor(&mut self, reactor: &mut Reactor) -> Result<(), CoreError> {
938 self.drain.register_with_reactor(reactor)
939 }
940
941 /// Apply one reactor readiness event to this process' stdio drain state.
942 ///
943 /// Events for unrelated tokens are ignored. Callers remain responsible for
944 /// waiting on [`Self::process`] and driving the reactor until [`Self::io_done`]
945 /// returns true.
946 pub fn handle_reactor_event(
947 &mut self,
948 reactor: &mut Reactor,
949 event: &crate::fd::Event,
950 ) -> Result<(), CoreError> {
951 if self.drain.stdout_matches(event.token) {
952 if event.readable || event.hangup {
953 self.drain.handle_stdout_ready(reactor)?;
954 } else if event.error {
955 self.drain.drop_stdout(reactor)?;
956 }
957 } else if self.drain.stderr_matches(event.token) {
958 if event.readable || event.hangup {
959 self.drain.handle_stderr_ready(reactor)?;
960 } else if event.error {
961 self.drain.drop_stderr(reactor)?;
962 }
963 } else if self.drain.stdin_matches(event.token) {
964 if event.writable {
965 self.drain.handle_stdin_writable(reactor)?;
966 } else if event.error || event.hangup {
967 self.drain.drop_stdin(reactor)?;
968 }
969 }
970 Ok(())
971 }
972
973 /// Return whether all managed stdio pipes have been drained or closed.
974 pub fn io_done(&self) -> bool {
975 self.drain.is_done()
976 }
977
978 /// Consume the running process handle and return captured stdout/stderr buffers.
979 pub fn into_output_parts(self) -> (Vec<u8>, Vec<u8>) {
980 self.drain.into_parts()
981 }
982}
983
984impl ManagedProcess {
985 /// Return the child PID.
986 ///
987 /// The PID is captured at spawn time, so this remains available after the
988 /// process has completed (unlike the running handle, which is consumed).
989 pub fn pid(&self) -> pid_t {
990 self.pid
991 }
992
993 /// Register active child I/O descriptors with the caller's reactor.
994 pub fn register_with_reactor(&mut self, reactor: &mut Reactor) -> Result<(), CoreError> {
995 self.running
996 .as_mut()
997 .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
998 .register_with_reactor(reactor)
999 }
1000
1001 /// Route one reactor event to the child's I/O drain state.
1002 pub fn handle_reactor_event(
1003 &mut self,
1004 reactor: &mut Reactor,
1005 event: &crate::fd::Event,
1006 ) -> Result<(), CoreError> {
1007 self.running
1008 .as_mut()
1009 .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1010 .handle_reactor_event(reactor, event)
1011 }
1012
1013 /// Request cancellation using the daemon-owned policy from
1014 /// [`SpawnOptionsBuilder::cancel`]. Repeated requests are idempotent.
1015 pub fn request_cancel(&mut self) {
1016 self.cancel_at.get_or_insert_with(Instant::now);
1017 }
1018
1019 /// Earliest time at which [`Self::poll_completion`] should run again.
1020 ///
1021 /// A bounded reap tick is returned while the child is live, and exact
1022 /// timeout / TERM-to-KILL deadlines take precedence. `None` means the
1023 /// completion was already consumed.
1024 pub fn next_deadline(&self) -> Option<Instant> {
1025 self.running.as_ref()?;
1026 let now = Instant::now();
1027 let mut next = now + Duration::from_millis(100);
1028 if !self.timed_out
1029 && let Some(timeout_at) = self.timeout_at
1030 && timeout_at < next
1031 {
1032 next = timeout_at;
1033 }
1034 if self.kill_state == KillState::TermSent
1035 && let Some(cancel_at) = self.cancel_at
1036 {
1037 let kill_at = cancel_at + self.kill_grace;
1038 if kill_at < next {
1039 next = kill_at;
1040 }
1041 }
1042 // D-state bound: wake the caller once the post-SIGKILL reap window has
1043 // elapsed so `poll_completion` can give up on an unreapable child.
1044 if let Some(sent_at) = self.kill_sent_at {
1045 let bail_at = sent_at + D_STATE_REAP_BOUND;
1046 if bail_at < next {
1047 next = bail_at;
1048 }
1049 }
1050 Some(next)
1051 }
1052
1053 /// Advance timeout/cancellation, reap state, and completion.
1054 ///
1055 /// Returns `Ok(None)` while work remains, the normal [`Output`] once the
1056 /// child is reaped and its pipes are drained, or `EOVERFLOW` when the
1057 /// configured combined output limit was exceeded on the fully-drained
1058 /// path. A forced-close (timeout/cancel with a wedged pipe) returns the
1059 /// partial output and the `timed_out` flag instead, matching blocking
1060 /// [`spawn`].
1061 pub fn poll_completion(&mut self, reactor: &mut Reactor) -> Result<Option<Output>, CoreError> {
1062 let now = Instant::now();
1063 if !self.timed_out
1064 && let Some(timeout_at) = self.timeout_at
1065 && now >= timeout_at
1066 {
1067 self.timed_out = true;
1068 self.cancel_at.get_or_insert(timeout_at);
1069 if self.cancel == CancelPolicy::None {
1070 // `CancelPolicy::None` never signals, so the D-state bound
1071 // below never fires; record when the deadline passed so the
1072 // give-up bound mirrors blocking `spawn` (finding 14).
1073 self.deadline_passed_at = Some(self.deadline_passed_at.unwrap_or(now));
1074 }
1075 }
1076
1077 self.advance_cancel(now)?;
1078
1079 let running = self
1080 .running
1081 .as_ref()
1082 .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
1083 if self.status.is_none() {
1084 self.status = running.process.wait_step()?;
1085 }
1086
1087 let io_done = running.io_done();
1088 if self.status.is_some() && (io_done || self.cancel_at.is_some()) {
1089 return self.finish(reactor, !io_done).map(Some);
1090 }
1091 // D-state: SIGKILL sent but the child still cannot be reaped. A child
1092 // stuck in uninterruptible sleep keeps the signal pending until it
1093 // leaves D-state; return the partial output instead of polling forever.
1094 if self.status.is_none()
1095 && self
1096 .kill_sent_at
1097 .is_some_and(|t| now.duration_since(t) >= D_STATE_REAP_BOUND)
1098 {
1099 return self.finish(reactor, true).map(Some);
1100 }
1101 // `CancelPolicy::None`: the deadline elapsed but nothing was ever
1102 // signaled, so a wedged child would poll forever. Give up with the
1103 // partial output after the same bound as the D-state path (finding 14).
1104 if self.status.is_none()
1105 && self.cancel == CancelPolicy::None
1106 && self
1107 .deadline_passed_at
1108 .is_some_and(|passed| now.duration_since(passed) >= D_STATE_REAP_BOUND)
1109 {
1110 return self.finish(reactor, true).map(Some);
1111 }
1112 Ok(None)
1113 }
1114
1115 fn advance_cancel(&mut self, now: Instant) -> Result<(), CoreError> {
1116 let Some(cancel_at) = self.cancel_at else {
1117 return Ok(());
1118 };
1119 // The child is already reaped — its pid may be recycled. Never signal.
1120 if self.status.is_some() {
1121 return Ok(());
1122 }
1123 let running = self
1124 .running
1125 .as_ref()
1126 .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
1127 let process = &running.process;
1128 let pid = process.pid();
1129 let pgid = effective_pgid(pid, self.pgroup);
1130 let target_is_group = self.pgroup.isolated || self.pgroup.leader.is_some();
1131 match self.kill_state {
1132 KillState::None => match self.cancel {
1133 CancelPolicy::None => {}
1134 CancelPolicy::Graceful => {
1135 let result = signal_process(process, target_is_group, pgid, libc::SIGTERM);
1136 self.kill_state = if result.is_ok() {
1137 KillState::TermSent
1138 } else {
1139 KillState::KillSent
1140 };
1141 if self.kill_state == KillState::KillSent {
1142 self.kill_sent_at = Some(now);
1143 }
1144 }
1145 CancelPolicy::Kill => {
1146 let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
1147 self.kill_state = KillState::KillSent;
1148 self.kill_sent_at = Some(now);
1149 }
1150 },
1151 KillState::TermSent if now >= cancel_at + self.kill_grace => {
1152 let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
1153 self.kill_state = KillState::KillSent;
1154 self.kill_sent_at = Some(now);
1155 }
1156 _ => {}
1157 }
1158 Ok(())
1159 }
1160
1161 fn finish(&mut self, reactor: &mut Reactor, force_close: bool) -> Result<Output, CoreError> {
1162 let mut running = self
1163 .running
1164 .take()
1165 .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
1166 for slot in running.drain.take_all_slots() {
1167 if force_close {
1168 let _ = reactor.del(&slot.fd);
1169 } else {
1170 reactor.del(&slot.fd)?;
1171 }
1172 }
1173 let pid = running.process.pid();
1174 let (stdout, stderr, output_limit_exceeded, stdout_early_exited) =
1175 running.drain.into_parts_with_state();
1176 // If the child was never reaped (D-state give-up / forced close with an
1177 // unreapable child), it will eventually exit and become a zombie — hand
1178 // it to the reaper so it does not accumulate in a long-lived daemon
1179 // (finding 15).
1180 if self.status.is_none() {
1181 orphan_child(pid);
1182 }
1183 // Mirror blocking `spawn`: overflow is reported only when the drain
1184 // completed naturally. On the forced-close path (timeout/cancel with a
1185 // wedged pipe) the caller gets the partial output and the timed-out
1186 // flag instead, matching the blocking N4 behavior.
1187 if output_limit_exceeded && !force_close {
1188 return Err(CoreError::sys(libc::EOVERFLOW, "spawn output limit"));
1189 }
1190 Ok(Output {
1191 pid,
1192 status: self.status.take(),
1193 stdout,
1194 stderr,
1195 timed_out: self.timed_out,
1196 stdout_early_exited,
1197 })
1198 }
1199}
1200
1201impl Drop for ManagedProcess {
1202 fn drop(&mut self) {
1203 let Some(running) = self.running.take() else {
1204 return;
1205 };
1206 // If the child was already reaped by `poll_completion`, the pid may
1207 // have been recycled — never signal it. The pipes are dropped with
1208 // `running`, so there is nothing left to clean up.
1209 if self.status.is_some() {
1210 return;
1211 }
1212 let process = &running.process;
1213 let pid = process.pid();
1214 // Respect CancelPolicy::None: "do nothing on cancellation" must not
1215 // kill the child on Drop either — the caller asked that cancellation
1216 // leave the child alone.
1217 if self.cancel != CancelPolicy::None {
1218 let pgid = effective_pgid(pid, self.pgroup);
1219 let target_is_group = self.pgroup.isolated || self.pgroup.leader.is_some();
1220 let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
1221 }
1222 // Bound the reap wait: SIGKILL terminates a runnable child
1223 // immediately, but a child stuck in uninterruptible sleep (D-state)
1224 // never dies. Poll with WNOHANG so `Drop` cannot wedge the caller's
1225 // reactor thread forever on a stuck child.
1226 let deadline = Instant::now() + Duration::from_millis(100);
1227 while Instant::now() < deadline {
1228 match process.wait_step() {
1229 Ok(Some(_)) => return,
1230 Ok(None) => std::thread::sleep(Duration::from_millis(5)),
1231 Err(_) => return,
1232 }
1233 }
1234 // Give-up: the child is unreapable right now (D-state) or still
1235 // running under `CancelPolicy::None`. Nobody will `waitpid` it now;
1236 // hand it to the reaper so it does not become a zombie on exit.
1237 orphan_child(pid);
1238 }
1239}
1240
1241fn effective_pgid(pid: pid_t, pgroup: ProcessGroup) -> pid_t {
1242 match pgroup.leader {
1243 Some(0) | None => pid,
1244 Some(leader) => leader,
1245 }
1246}
1247
1248fn signal_process(
1249 process: &Process,
1250 target_is_group: bool,
1251 pgid: pid_t,
1252 signal: i32,
1253) -> Result<(), CoreError> {
1254 if target_is_group {
1255 process.kill_group(pgid, signal)
1256 } else {
1257 process.kill(signal)
1258 }
1259}
1260
1261/// Start spawning a process and return a monitor handle.
1262///
1263/// This initializes the pipes and starts the process, but does not block. Use
1264/// [`RunningProcess::register_with_reactor`],
1265/// [`RunningProcess::handle_reactor_event`], [`RunningProcess::io_done`], and
1266/// [`RunningProcess::into_output_parts`] to drive captured stdio without
1267/// exposing internal drain state.
1268///
1269/// ### Errors
1270/// - `EACCES`: Permission denied for the executable.
1271/// - `EINVAL`: Invalid spawn options (e.g. background capture without wait).
1272/// - `EMFILE`: Process limit on open file descriptors hit.
1273/// - `ENOENT`: The executable was not found.
1274/// - `ENOMEM`: Insufficient memory to spawn the process.
1275pub fn spawn_start(opts: SpawnOptions) -> Result<RunningProcess, CoreError> {
1276 if !opts.wait && (opts.stdin.is_some() || opts.capture_stdout || opts.capture_stderr) {
1277 return Err(CoreError::sys(
1278 libc::EINVAL,
1279 "background I/O capture not supported (wait must be true)",
1280 ));
1281 }
1282
1283 validate_backend(&opts)?;
1284
1285 let (process, drain) = match opts.backend {
1286 SpawnBackend::PosixSpawn => spawn_posix_internal(opts)?,
1287 SpawnBackend::Fork => spawn_fork_internal(opts)?,
1288 SpawnBackend::Vfork => spawn_vfork_internal(opts)?,
1289 SpawnBackend::Clone3 => spawn_clone3_internal(opts, false)?,
1290 SpawnBackend::Clone3Pidfd => spawn_clone3_internal(opts, true)?,
1291 };
1292
1293 Ok(RunningProcess { process, drain })
1294}
1295
1296/// Start a process whose complete lifecycle is driven by a caller-owned
1297/// reactor.
1298pub fn spawn_managed(opts: SpawnOptions) -> Result<ManagedProcess, CoreError> {
1299 if !opts.wait {
1300 return Err(CoreError::sys(
1301 libc::EINVAL,
1302 "managed process requires wait=true",
1303 ));
1304 }
1305 let timeout_at = opts
1306 .timeout_ms
1307 .map(|ms| Instant::now() + Duration::from_millis(ms as u64));
1308 let kill_grace = Duration::from_millis(opts.kill_grace_ms as u64);
1309 let cancel = opts.cancel;
1310 let pgroup = opts.pgroup;
1311 let running = spawn_start(opts)?;
1312 let pid = running.process.pid();
1313 Ok(ManagedProcess {
1314 running: Some(running),
1315 pid,
1316 timeout_at,
1317 kill_grace,
1318 cancel,
1319 pgroup,
1320 cancel_at: None,
1321 kill_state: KillState::None,
1322 status: None,
1323 timed_out: false,
1324 kill_sent_at: None,
1325 deadline_passed_at: None,
1326 })
1327}
1328
1329/// Spawn a process and block until completion or timeout.
1330///
1331/// This is the primary high-level interface for process execution. It handles
1332/// the full lifecycle, including I/O multiplexing and signal management.
1333///
1334/// ### Errors
1335/// Returns the same errors as [`spawn_start`], plus any I/O or reactor errors
1336/// encountered during the wait loop.
1337pub fn spawn(opts: SpawnOptions) -> Result<Output, CoreError> {
1338 let wait = opts.wait;
1339 let timeout_ms = opts.timeout_ms;
1340 let kill_grace_ms = opts.kill_grace_ms;
1341 let cancel = opts.cancel;
1342 let pgroup = opts.pgroup;
1343
1344 let mut reactor = Reactor::new()?;
1345 let running = spawn_start(opts)?;
1346
1347 let pid = running.process.pid();
1348 let mut drain = running.drain;
1349
1350 if let Err(e) = drain.register_with_reactor(&mut reactor) {
1351 // The child is live but stdio registration failed; `running` is
1352 // dropped here so nobody will `waitpid` it. Hand it to the reaper.
1353 orphan_child(pid);
1354 return Err(e);
1355 }
1356
1357 if !wait {
1358 let (stdout, stderr) = drain.into_parts();
1359 // The caller will never `wait` on this pid — hand it to the reaper so
1360 // it does not become a zombie when it exits (finding 15).
1361 orphan_child(pid);
1362 return Ok(Output {
1363 pid,
1364 status: None,
1365 stdout,
1366 stderr,
1367 timed_out: false,
1368 stdout_early_exited: false,
1369 });
1370 }
1371
1372 wait_loop(
1373 running.process,
1374 drain,
1375 reactor,
1376 timeout_ms,
1377 kill_grace_ms,
1378 cancel,
1379 pgroup,
1380 )
1381}
1382
1383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1384enum KillState {
1385 None,
1386 TermSent,
1387 KillSent,
1388}
1389
1390fn wait_loop(
1391 process: Process,
1392 mut drain: crate::io::DrainState<fn(&[u8]) -> bool>,
1393 mut reactor: Reactor,
1394 timeout_ms: Option<u32>,
1395 kill_grace_ms: u32,
1396 cancel: CancelPolicy,
1397 pgroup: ProcessGroup,
1398) -> Result<Output, CoreError> {
1399 let pid = process.pid();
1400 // M8: the child's effective pgid is the configured leader when one is set
1401 // (Setpgid is applied after Setsid in the child), else its own pid. A
1402 // timeout must signal `-pgid`; `kill(-pid)` would target a different
1403 // group for a custom leader and the child would never die.
1404 let pgid = effective_pgid(pid, pgroup);
1405 let mut status_raw = process.wait_step()?;
1406 let mut state = KillState::None;
1407 let mut timed_out = false;
1408 // D-state bound: recorded once SIGKILL has been sent. If the child still
1409 // refuses to die (or be reaped) after `D_STATE_REAP_BOUND`, give up and
1410 // return the partial output instead of spinning on a stuck child.
1411 let mut kill_sent_at: Option<Instant> = None;
1412 // Deadline give-up bound for `CancelPolicy::None`: no signal is ever sent,
1413 // so `kill_sent_at` stays unset and the D-state bound never fires. A wedged
1414 // child (pipe held open by a descendant, child unreaped) would otherwise
1415 // poll at 100 ms forever. Once the deadline has passed we give up after the
1416 // same bound, returning the partial output with `timed_out` set.
1417 let mut deadline_passed_at: Option<Instant> = None;
1418
1419 let start_time = std::time::Instant::now();
1420 let deadline = timeout_ms.map(|t| std::time::Duration::from_millis(t as u64));
1421
1422 loop {
1423 let mut poll_timeout = -1;
1424
1425 if let Some(dl) = deadline {
1426 let elapsed = start_time.elapsed();
1427 if elapsed >= dl {
1428 timed_out = true;
1429 deadline_passed_at = Some(deadline_passed_at.unwrap_or_else(Instant::now));
1430 let elapsed_over = (elapsed - dl).as_millis();
1431
1432 let target_is_group = pgroup.isolated || pgroup.leader.is_some();
1433
1434 // Only signal while the child is unreaped. Once waitpid has
1435 // reaped it the pid may already be recycled by the OS — killing
1436 // it would hit an unrelated process. The wedged-pipe path below
1437 // returns the partial output without sending any signal.
1438 if status_raw.is_none() {
1439 match state {
1440 KillState::None => {
1441 if cancel == CancelPolicy::Graceful {
1442 let r = if target_is_group {
1443 process.kill_group(pgid, libc::SIGTERM)
1444 } else {
1445 process.kill(libc::SIGTERM)
1446 };
1447 if r.is_err() {
1448 state = KillState::KillSent; // Process already gone
1449 kill_sent_at = Some(Instant::now());
1450 } else {
1451 state = KillState::TermSent;
1452 }
1453 } else if cancel == CancelPolicy::Kill {
1454 let _ = if target_is_group {
1455 process.kill_group(pgid, libc::SIGKILL)
1456 } else {
1457 process.kill(libc::SIGKILL)
1458 };
1459 state = KillState::KillSent;
1460 kill_sent_at = Some(Instant::now());
1461 } else {
1462 // CancelPolicy::None just times out without killing
1463 }
1464 }
1465 KillState::TermSent if elapsed_over > kill_grace_ms as u128 => {
1466 let _ = if target_is_group {
1467 process.kill_group(pgid, libc::SIGKILL)
1468 } else {
1469 process.kill(libc::SIGKILL)
1470 };
1471 state = KillState::KillSent;
1472 kill_sent_at = Some(Instant::now());
1473 }
1474 _ => {}
1475 }
1476 }
1477 poll_timeout = 100; // Poll frequently while waiting for kill to take effect
1478 } else {
1479 let remaining = dl - elapsed;
1480 poll_timeout = remaining.as_millis().min(i32::MAX as u128) as i32;
1481 }
1482 }
1483
1484 if status_raw.is_none()
1485 && let Some(s) = process.wait_step()?
1486 {
1487 status_raw = Some(s);
1488 }
1489
1490 if drain.is_done() {
1491 let s = if status_raw.is_some() {
1492 status_raw.take()
1493 } else if deadline.is_none() {
1494 // C1: all pipes drained but the child is still alive, and no
1495 // deadline is set → block until it exits (intended semantics).
1496 Some(process.wait_blocking()?)
1497 } else {
1498 // C1: pipes drained with a deadline set → never block here; fall
1499 // through to the bounded `reactor.wait` below so the deadline
1500 // logic at the top of the loop kills and reaps. A later
1501 // `wait_step` reaps the child and we return from this branch.
1502 None
1503 };
1504
1505 if let Some(s) = s {
1506 for slot in drain.take_all_slots() {
1507 reactor.del(&slot.fd)?;
1508 }
1509 let (stdout, stderr, output_limit_exceeded, stdout_early_exited) =
1510 drain.into_parts_with_state();
1511 if output_limit_exceeded {
1512 return Err(CoreError::sys(libc::EOVERFLOW, "spawn output limit"));
1513 }
1514 return Ok(Output {
1515 pid,
1516 status: Some(s),
1517 stdout,
1518 stderr,
1519 timed_out,
1520 stdout_early_exited,
1521 });
1522 }
1523 }
1524
1525 // N4: the deadline has elapsed and the child is reaped, but a wedged
1526 // pipe (a descendant inheriting the write end) keeps the drain from
1527 // closing. The absolute deadline is authoritative — return the partial
1528 // output instead of spinning forever.
1529 if timed_out && status_raw.is_some() {
1530 for slot in drain.take_all_slots() {
1531 let _ = reactor.del(&slot.fd);
1532 }
1533 let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
1534 drain.into_parts_with_state();
1535 return Ok(Output {
1536 pid,
1537 status: status_raw,
1538 stdout,
1539 stderr,
1540 timed_out: true,
1541 stdout_early_exited,
1542 });
1543 }
1544
1545 // D-state: SIGKILL has been sent but the child is still unreaped after
1546 // the bound. A child stuck in uninterruptible sleep keeps the signal
1547 // pending until it leaves D-state, so no further wait can succeed —
1548 // return the partial output rather than polling forever. The pid is
1549 // not signaled again (it may be recycled once it finally exits).
1550 if let Some(sent_at) = kill_sent_at
1551 && sent_at.elapsed() >= D_STATE_REAP_BOUND
1552 && status_raw.is_none()
1553 {
1554 for slot in drain.take_all_slots() {
1555 let _ = reactor.del(&slot.fd);
1556 }
1557 // The child is unreapable right now but will eventually leave
1558 // D-state and exit; nobody will wait on it after this give-up, so
1559 // hand it to the reaper (finding 15).
1560 orphan_child(pid);
1561 let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
1562 drain.into_parts_with_state();
1563 return Ok(Output {
1564 pid,
1565 status: None,
1566 stdout,
1567 stderr,
1568 timed_out: true,
1569 stdout_early_exited,
1570 });
1571 }
1572
1573 // `CancelPolicy::None`: the deadline elapsed but nothing was ever
1574 // signaled, so the child may stay wedged (pipe held by a descendant,
1575 // child unreaped) indefinitely. Give up with the partial output after
1576 // the same bound as the D-state path — otherwise this polls at 100 ms
1577 // forever (finding 14).
1578 if cancel == CancelPolicy::None
1579 && timed_out
1580 && status_raw.is_none()
1581 && deadline_passed_at.is_some_and(|passed| passed.elapsed() >= D_STATE_REAP_BOUND)
1582 {
1583 for slot in drain.take_all_slots() {
1584 let _ = reactor.del(&slot.fd);
1585 }
1586 // The child was never signaled and may still be running; nobody
1587 // will wait on it now — hand it to the reaper (finding 15).
1588 orphan_child(pid);
1589 let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
1590 drain.into_parts_with_state();
1591 return Ok(Output {
1592 pid,
1593 status: None,
1594 stdout,
1595 stderr,
1596 timed_out: true,
1597 stdout_early_exited,
1598 });
1599 }
1600
1601 let timeout = poll_timeout;
1602
1603 let mut events = Vec::new();
1604 let nevents = reactor.wait(&mut events, 64, timeout)?;
1605
1606 for ev in events.iter().take(nevents) {
1607 if drain.stdout_matches(ev.token) {
1608 if ev.readable || ev.hangup {
1609 drain.handle_stdout_ready(&mut reactor)?;
1610 } else if ev.error {
1611 drain.drop_stdout(&mut reactor)?;
1612 }
1613 } else if drain.stderr_matches(ev.token) {
1614 if ev.readable || ev.hangup {
1615 drain.handle_stderr_ready(&mut reactor)?;
1616 } else if ev.error {
1617 drain.drop_stderr(&mut reactor)?;
1618 }
1619 } else if drain.stdin_matches(ev.token) {
1620 if ev.writable {
1621 drain.handle_stdin_writable(&mut reactor)?;
1622 } else if ev.error || ev.hangup {
1623 drain.drop_stdin(&mut reactor)?;
1624 }
1625 }
1626 }
1627 }
1628}