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