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