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