Skip to main content

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}