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    /// Write bytes to a pty-spawned child's stdin (the master end).
1237    ///
1238    /// Only valid for a [`SpawnOptionsBuilder::pty`] spawn with an active
1239    /// stdout stream; the write is accepted by the tty line discipline and
1240    /// delivered to the child as its stdin.
1241    ///
1242    /// ### Errors
1243    /// - `EINVAL`: The spawn was not a pty spawn, or the stream is already
1244    ///   closed.
1245    /// - `EIO`: All slave holders have closed (master-side write failure).
1246    /// - `ETIMEDOUT`: The child did not drain its input within the bound.
1247    pub fn write_input(&self, bytes: &[u8]) -> Result<usize, CoreError> {
1248        self.drain.write_input(bytes)
1249    }
1250}
1251
1252impl ManagedProcess {
1253    /// Return the child PID.
1254    ///
1255    /// The PID is captured at spawn time, so this remains available after the
1256    /// process has completed (unlike the running handle, which is consumed).
1257    pub fn pid(&self) -> pid_t {
1258        self.pid
1259    }
1260
1261    /// Register active child I/O descriptors with the caller's reactor.
1262    pub fn register_with_reactor(&mut self, reactor: &mut Reactor) -> Result<(), CoreError> {
1263        self.running
1264            .as_mut()
1265            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1266            .register_with_reactor(reactor)
1267    }
1268
1269    /// Route one reactor event to the child's I/O drain state.
1270    pub fn handle_reactor_event(
1271        &mut self,
1272        reactor: &mut Reactor,
1273        event: &crate::fd::Event,
1274    ) -> Result<(), CoreError> {
1275        self.running
1276            .as_mut()
1277            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1278            .handle_reactor_event(reactor, event)
1279    }
1280
1281    /// Return whether the stdout stream is paused on a full sink queue.
1282    pub fn stdout_paused(&self) -> bool {
1283        self.running
1284            .as_ref()
1285            .is_some_and(|running| running.stdout_paused())
1286    }
1287
1288    /// Return whether the stderr stream is paused on a full sink queue.
1289    pub fn stderr_paused(&self) -> bool {
1290        self.running
1291            .as_ref()
1292            .is_some_and(|running| running.stderr_paused())
1293    }
1294
1295    /// Re-deliver the held stdout chunk (if any) and re-register the fd when
1296    /// the sink has room again. Returns `true` when the stream is resumed.
1297    pub fn resume_stdout(&mut self, reactor: &mut Reactor) -> Result<bool, CoreError> {
1298        self.running
1299            .as_mut()
1300            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1301            .resume_stdout(reactor)
1302    }
1303
1304    /// Re-deliver the held stderr chunk (if any) and re-register the fd when
1305    /// the sink has room again. Returns `true` when the stream is resumed.
1306    pub fn resume_stderr(&mut self, reactor: &mut Reactor) -> Result<bool, CoreError> {
1307        self.running
1308            .as_mut()
1309            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1310            .resume_stderr(reactor)
1311    }
1312
1313    /// Request cancellation using the daemon-owned policy from
1314    /// [`SpawnOptionsBuilder::cancel`]. Repeated requests are idempotent.
1315    pub fn request_cancel(&mut self) {
1316        self.cancel_at.get_or_insert_with(Instant::now);
1317    }
1318
1319    /// Earliest time at which [`Self::poll_completion`] should run again.
1320    ///
1321    /// A bounded reap tick is returned while the child is live, and exact
1322    /// timeout / TERM-to-KILL deadlines take precedence. `None` means the
1323    /// completion was already consumed.
1324    pub fn next_deadline(&self) -> Option<Instant> {
1325        self.running.as_ref()?;
1326        let now = Instant::now();
1327        let mut next = now + Duration::from_millis(100);
1328        if !self.timed_out
1329            && let Some(timeout_at) = self.timeout_at
1330            && timeout_at < next
1331        {
1332            next = timeout_at;
1333        }
1334        if self.kill_state == KillState::TermSent
1335            && let Some(cancel_at) = self.cancel_at
1336        {
1337            let kill_at = cancel_at + self.kill_grace;
1338            if kill_at < next {
1339                next = kill_at;
1340            }
1341        }
1342        // D-state bound: wake the caller once the post-SIGKILL reap window has
1343        // elapsed so `poll_completion` can give up on an unreapable child.
1344        if let Some(sent_at) = self.kill_sent_at {
1345            let bail_at = sent_at + D_STATE_REAP_BOUND;
1346            if bail_at < next {
1347                next = bail_at;
1348            }
1349        }
1350        Some(next)
1351    }
1352
1353    /// Advance timeout/cancellation, reap state, and completion.
1354    ///
1355    /// Returns `Ok(None)` while work remains, the normal [`Output`] once the
1356    /// child is reaped and its pipes are drained, or `EOVERFLOW` when the
1357    /// configured combined output limit was exceeded on the fully-drained
1358    /// path. A forced-close (timeout/cancel with a wedged pipe) returns the
1359    /// partial output and the `timed_out` flag instead, matching blocking
1360    /// [`spawn`].
1361    pub fn poll_completion(&mut self, reactor: &mut Reactor) -> Result<Option<Output>, CoreError> {
1362        let now = Instant::now();
1363        if !self.timed_out
1364            && let Some(timeout_at) = self.timeout_at
1365            && now >= timeout_at
1366        {
1367            self.timed_out = true;
1368            self.cancel_at.get_or_insert(timeout_at);
1369            if self.cancel == CancelPolicy::None {
1370                // `CancelPolicy::None` never signals, so the D-state bound
1371                // below never fires; record when the deadline passed so the
1372                // give-up bound mirrors blocking `spawn` (finding 14).
1373                self.deadline_passed_at = Some(self.deadline_passed_at.unwrap_or(now));
1374            }
1375        }
1376
1377        self.advance_cancel(now)?;
1378
1379        let running = self
1380            .running
1381            .as_ref()
1382            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
1383        if self.status.is_none() {
1384            self.status = running.process.wait_step()?;
1385        }
1386
1387        let io_done = running.io_done();
1388        let paused = running.stdout_paused() || running.stderr_paused();
1389        // A paused stream (full sink queue, fd removed from the reactor) can
1390        // never make progress on its own: once the child is reaped, finish with
1391        // the partial output and the held pending chunk instead of waiting for
1392        // a readiness event that will never arrive.
1393        if self.status.is_some() && (io_done || self.cancel_at.is_some() || paused) {
1394            return self.finish(reactor, !io_done).map(Some);
1395        }
1396        // D-state: SIGKILL sent but the child still cannot be reaped. A child
1397        // stuck in uninterruptible sleep keeps the signal pending until it
1398        // leaves D-state; return the partial output instead of polling forever.
1399        if self.status.is_none()
1400            && self
1401                .kill_sent_at
1402                .is_some_and(|t| now.duration_since(t) >= D_STATE_REAP_BOUND)
1403        {
1404            return self.finish(reactor, true).map(Some);
1405        }
1406        // `CancelPolicy::None`: the deadline elapsed but nothing was ever
1407        // signaled, so a wedged child would poll forever. Give up with the
1408        // partial output after the same bound as the D-state path (finding 14).
1409        if self.status.is_none()
1410            && self.cancel == CancelPolicy::None
1411            && self
1412                .deadline_passed_at
1413                .is_some_and(|passed| now.duration_since(passed) >= D_STATE_REAP_BOUND)
1414        {
1415            return self.finish(reactor, true).map(Some);
1416        }
1417        Ok(None)
1418    }
1419
1420    fn advance_cancel(&mut self, now: Instant) -> Result<(), CoreError> {
1421        let Some(cancel_at) = self.cancel_at else {
1422            return Ok(());
1423        };
1424        // The child is already reaped — its pid may be recycled. Never signal.
1425        if self.status.is_some() {
1426            return Ok(());
1427        }
1428        let running = self
1429            .running
1430            .as_ref()
1431            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
1432        let process = &running.process;
1433        let pid = process.pid();
1434        let pgid = effective_pgid(pid, self.pgroup);
1435        let target_is_group = self.pgroup.isolated || self.pgroup.leader.is_some();
1436        match self.kill_state {
1437            KillState::None => match self.cancel {
1438                CancelPolicy::None => {}
1439                CancelPolicy::Graceful => {
1440                    let result = signal_process(process, target_is_group, pgid, libc::SIGTERM);
1441                    self.kill_state = if result.is_ok() {
1442                        KillState::TermSent
1443                    } else {
1444                        KillState::KillSent
1445                    };
1446                    if self.kill_state == KillState::KillSent {
1447                        self.kill_sent_at = Some(now);
1448                    }
1449                }
1450                CancelPolicy::Kill => {
1451                    let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
1452                    self.kill_state = KillState::KillSent;
1453                    self.kill_sent_at = Some(now);
1454                }
1455            },
1456            KillState::TermSent if now >= cancel_at + self.kill_grace => {
1457                let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
1458                self.kill_state = KillState::KillSent;
1459                self.kill_sent_at = Some(now);
1460            }
1461            _ => {}
1462        }
1463        Ok(())
1464    }
1465
1466    fn finish(&mut self, reactor: &mut Reactor, force_close: bool) -> Result<Output, CoreError> {
1467        let mut running = self
1468            .running
1469            .take()
1470            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
1471        for slot in running.drain.take_all_slots() {
1472            if slot.token.is_none() {
1473                continue;
1474            }
1475            if force_close {
1476                let _ = reactor.del(&slot.fd);
1477            } else {
1478                reactor.del(&slot.fd)?;
1479            }
1480        }
1481        let pid = running.process.pid();
1482        let stdout_pending = running.drain.take_stdout_pending();
1483        let stderr_pending = running.drain.take_stderr_pending();
1484        let (stdout, stderr, output_limit_exceeded, stdout_early_exited) =
1485            running.drain.into_parts_with_state();
1486        // If the child was never reaped (D-state give-up / forced close with an
1487        // unreapable child), it will eventually exit and become a zombie — hand
1488        // it to the reaper so it does not accumulate in a long-lived daemon
1489        // (finding 15).
1490        if self.status.is_none() {
1491            orphan_child(pid);
1492        }
1493        // Mirror blocking `spawn`: overflow is reported only when the drain
1494        // completed naturally. On the forced-close path (timeout/cancel with a
1495        // wedged pipe) the caller gets the partial output and the timed-out
1496        // flag instead, matching the blocking N4 behavior.
1497        if output_limit_exceeded && !force_close {
1498            return Err(CoreError::sys(libc::EOVERFLOW, "spawn output limit"));
1499        }
1500        Ok(Output {
1501            pid,
1502            status: self.status.take(),
1503            stdout,
1504            stderr,
1505            timed_out: self.timed_out,
1506            stdout_early_exited,
1507            stdout_pending,
1508            stderr_pending,
1509        })
1510    }
1511
1512    /// Apply a new terminal window size to a pty-spawned child.
1513    ///
1514    /// Only valid for a [`SpawnOptionsBuilder::pty`] spawn with an active
1515    /// stdout stream; callers typically follow this with a `SIGWINCH` to the
1516    /// child (or its foreground group) so the program can re-read the size.
1517    ///
1518    /// ### Errors
1519    /// - `EINVAL`: The spawn was not a pty spawn, the stream is already
1520    ///   closed, or `rows`/`cols` is zero.
1521    /// - `ENOTTY`: The pty master is unexpectedly not a terminal.
1522    pub fn resize_pty(&self, rows: u16, cols: u16) -> Result<(), CoreError> {
1523        self.running
1524            .as_ref()
1525            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1526            .resize_pty(rows, cols)
1527    }
1528
1529    /// Write bytes to a pty-spawned child's stdin (the master end).
1530    ///
1531    /// Only valid for a [`SpawnOptionsBuilder::pty`] spawn with an active
1532    /// stdout stream. See [`RunningProcess::write_input`].
1533    pub fn write_input(&self, bytes: &[u8]) -> Result<usize, CoreError> {
1534        self.running
1535            .as_ref()
1536            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1537            .write_input(bytes)
1538    }
1539}
1540
1541impl Drop for ManagedProcess {
1542    fn drop(&mut self) {
1543        let Some(running) = self.running.take() else {
1544            return;
1545        };
1546        // If the child was already reaped by `poll_completion`, the pid may
1547        // have been recycled — never signal it. The pipes are dropped with
1548        // `running`, so there is nothing left to clean up.
1549        if self.status.is_some() {
1550            return;
1551        }
1552        let process = &running.process;
1553        let pid = process.pid();
1554        // Respect CancelPolicy::None: "do nothing on cancellation" must not
1555        // kill the child on Drop either — the caller asked that cancellation
1556        // leave the child alone.
1557        if self.cancel != CancelPolicy::None {
1558            let pgid = effective_pgid(pid, self.pgroup);
1559            let target_is_group = self.pgroup.isolated || self.pgroup.leader.is_some();
1560            let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
1561        }
1562        // Bound the reap wait: SIGKILL terminates a runnable child
1563        // immediately, but a child stuck in uninterruptible sleep (D-state)
1564        // never dies. Poll with WNOHANG so `Drop` cannot wedge the caller's
1565        // reactor thread forever on a stuck child.
1566        let deadline = Instant::now() + Duration::from_millis(100);
1567        while Instant::now() < deadline {
1568            match process.wait_step() {
1569                Ok(Some(_)) => return,
1570                Ok(None) => std::thread::sleep(Duration::from_millis(5)),
1571                Err(_) => return,
1572            }
1573        }
1574        // Give-up: the child is unreapable right now (D-state) or still
1575        // running under `CancelPolicy::None`. Nobody will `waitpid` it now;
1576        // hand it to the reaper so it does not become a zombie on exit.
1577        orphan_child(pid);
1578    }
1579}
1580
1581fn effective_pgid(pid: pid_t, pgroup: ProcessGroup) -> pid_t {
1582    match pgroup.leader {
1583        Some(0) | None => pid,
1584        Some(leader) => leader,
1585    }
1586}
1587
1588fn signal_process(
1589    process: &Process,
1590    target_is_group: bool,
1591    pgid: pid_t,
1592    signal: i32,
1593) -> Result<(), CoreError> {
1594    if target_is_group {
1595        process.kill_group(pgid, signal)
1596    } else {
1597        process.kill(signal)
1598    }
1599}
1600
1601/// Start spawning a process and return a monitor handle.
1602///
1603/// This initializes the pipes and starts the process, but does not block. Use
1604/// [`RunningProcess::register_with_reactor`],
1605/// [`RunningProcess::handle_reactor_event`], [`RunningProcess::io_done`], and
1606/// [`RunningProcess::into_output_parts`] to drive captured stdio without
1607/// exposing internal drain state.
1608///
1609/// ### Errors
1610/// - `EACCES`: Permission denied for the executable.
1611/// - `EINVAL`: Invalid spawn options (e.g. background capture without wait).
1612/// - `EMFILE`: Process limit on open file descriptors hit.
1613/// - `ENOENT`: The executable was not found.
1614/// - `ENOMEM`: Insufficient memory to spawn the process.
1615pub fn spawn_start(opts: SpawnOptions) -> Result<RunningProcess, CoreError> {
1616    if !opts.wait && (opts.stdin.is_some() || opts.capture_stdout || opts.capture_stderr) {
1617        return Err(CoreError::sys(
1618            libc::EINVAL,
1619            "background I/O capture not supported (wait must be true)",
1620        ));
1621    }
1622
1623    validate_backend(&opts)?;
1624
1625    let (process, drain) = match opts.backend {
1626        SpawnBackend::PosixSpawn => spawn_posix_internal(opts)?,
1627        SpawnBackend::Fork => spawn_fork_internal(opts)?,
1628        SpawnBackend::Vfork => spawn_vfork_internal(opts)?,
1629        SpawnBackend::Clone3 => spawn_clone3_internal(opts, false)?,
1630        SpawnBackend::Clone3Pidfd => spawn_clone3_internal(opts, true)?,
1631    };
1632
1633    Ok(RunningProcess { process, drain })
1634}
1635
1636/// Start a process whose complete lifecycle is driven by a caller-owned
1637/// reactor.
1638pub fn spawn_managed(opts: SpawnOptions) -> Result<ManagedProcess, CoreError> {
1639    if !opts.wait {
1640        return Err(CoreError::sys(
1641            libc::EINVAL,
1642            "managed process requires wait=true",
1643        ));
1644    }
1645    let timeout_at = opts
1646        .timeout_ms
1647        .map(|ms| Instant::now() + Duration::from_millis(ms as u64));
1648    let kill_grace = Duration::from_millis(opts.kill_grace_ms as u64);
1649    let cancel = opts.cancel;
1650    let pgroup = opts.pgroup;
1651    let running = spawn_start(opts)?;
1652    let pid = running.process.pid();
1653    Ok(ManagedProcess {
1654        running: Some(running),
1655        pid,
1656        timeout_at,
1657        kill_grace,
1658        cancel,
1659        pgroup,
1660        cancel_at: None,
1661        kill_state: KillState::None,
1662        status: None,
1663        timed_out: false,
1664        kill_sent_at: None,
1665        deadline_passed_at: None,
1666    })
1667}
1668
1669/// Spawn a process and block until completion or timeout.
1670///
1671/// This is the primary high-level interface for process execution. It handles
1672/// the full lifecycle, including I/O multiplexing and signal management.
1673///
1674/// ### Errors
1675/// Returns the same errors as [`spawn_start`], plus any I/O or reactor errors
1676/// encountered during the wait loop.
1677pub fn spawn(opts: SpawnOptions) -> Result<Output, CoreError> {
1678    let wait = opts.wait;
1679    let timeout_ms = opts.timeout_ms;
1680    let kill_grace_ms = opts.kill_grace_ms;
1681    let cancel = opts.cancel;
1682    let pgroup = opts.pgroup;
1683
1684    let mut reactor = Reactor::new()?;
1685    let running = spawn_start(opts)?;
1686
1687    let pid = running.process.pid();
1688    let mut drain = running.drain;
1689
1690    if let Err(e) = drain.register_with_reactor(&mut reactor) {
1691        // The child is live but stdio registration failed; `running` is
1692        // dropped here so nobody will `waitpid` it. Hand it to the reaper.
1693        orphan_child(pid);
1694        return Err(e);
1695    }
1696
1697    if !wait {
1698        let (stdout, stderr) = drain.into_parts();
1699        // The caller will never `wait` on this pid — hand it to the reaper so
1700        // it does not become a zombie when it exits (finding 15).
1701        orphan_child(pid);
1702        return Ok(Output {
1703            pid,
1704            status: None,
1705            stdout,
1706            stderr,
1707            timed_out: false,
1708            stdout_early_exited: false,
1709            stdout_pending: None,
1710            stderr_pending: None,
1711        });
1712    }
1713
1714    wait_loop(
1715        running.process,
1716        drain,
1717        reactor,
1718        timeout_ms,
1719        kill_grace_ms,
1720        cancel,
1721        pgroup,
1722    )
1723}
1724
1725#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1726enum KillState {
1727    None,
1728    TermSent,
1729    KillSent,
1730}
1731
1732fn wait_loop(
1733    process: Process,
1734    mut drain: crate::io::DrainState<fn(&[u8]) -> bool>,
1735    mut reactor: Reactor,
1736    timeout_ms: Option<u32>,
1737    kill_grace_ms: u32,
1738    cancel: CancelPolicy,
1739    pgroup: ProcessGroup,
1740) -> Result<Output, CoreError> {
1741    let pid = process.pid();
1742    // M8: the child's effective pgid is the configured leader when one is set
1743    // (Setpgid is applied after Setsid in the child), else its own pid. A
1744    // timeout must signal `-pgid`; `kill(-pid)` would target a different
1745    // group for a custom leader and the child would never die.
1746    let pgid = effective_pgid(pid, pgroup);
1747    let mut status_raw = process.wait_step()?;
1748    let mut state = KillState::None;
1749    let mut timed_out = false;
1750    // D-state bound: recorded once SIGKILL has been sent. If the child still
1751    // refuses to die (or be reaped) after `D_STATE_REAP_BOUND`, give up and
1752    // return the partial output instead of spinning on a stuck child.
1753    let mut kill_sent_at: Option<Instant> = None;
1754    // Deadline give-up bound for `CancelPolicy::None`: no signal is ever sent,
1755    // so `kill_sent_at` stays unset and the D-state bound never fires. A wedged
1756    // child (pipe held open by a descendant, child unreaped) would otherwise
1757    // poll at 100 ms forever. Once the deadline has passed we give up after the
1758    // same bound, returning the partial output with `timed_out` set.
1759    let mut deadline_passed_at: Option<Instant> = None;
1760
1761    let start_time = std::time::Instant::now();
1762    let deadline = timeout_ms.map(|t| std::time::Duration::from_millis(t as u64));
1763
1764    loop {
1765        let mut poll_timeout = -1;
1766
1767        if let Some(dl) = deadline {
1768            let elapsed = start_time.elapsed();
1769            if elapsed >= dl {
1770                timed_out = true;
1771                deadline_passed_at = Some(deadline_passed_at.unwrap_or_else(Instant::now));
1772                let elapsed_over = (elapsed - dl).as_millis();
1773
1774                let target_is_group = pgroup.isolated || pgroup.leader.is_some();
1775
1776                // Only signal while the child is unreaped. Once waitpid has
1777                // reaped it the pid may already be recycled by the OS — killing
1778                // it would hit an unrelated process. The wedged-pipe path below
1779                // returns the partial output without sending any signal.
1780                if status_raw.is_none() {
1781                    match state {
1782                        KillState::None => {
1783                            if cancel == CancelPolicy::Graceful {
1784                                let r = if target_is_group {
1785                                    process.kill_group(pgid, libc::SIGTERM)
1786                                } else {
1787                                    process.kill(libc::SIGTERM)
1788                                };
1789                                if r.is_err() {
1790                                    state = KillState::KillSent; // Process already gone
1791                                    kill_sent_at = Some(Instant::now());
1792                                } else {
1793                                    state = KillState::TermSent;
1794                                }
1795                            } else if cancel == CancelPolicy::Kill {
1796                                let _ = if target_is_group {
1797                                    process.kill_group(pgid, libc::SIGKILL)
1798                                } else {
1799                                    process.kill(libc::SIGKILL)
1800                                };
1801                                state = KillState::KillSent;
1802                                kill_sent_at = Some(Instant::now());
1803                            } else {
1804                                // CancelPolicy::None just times out without killing
1805                            }
1806                        }
1807                        KillState::TermSent if elapsed_over > kill_grace_ms as u128 => {
1808                            let _ = if target_is_group {
1809                                process.kill_group(pgid, libc::SIGKILL)
1810                            } else {
1811                                process.kill(libc::SIGKILL)
1812                            };
1813                            state = KillState::KillSent;
1814                            kill_sent_at = Some(Instant::now());
1815                        }
1816                        _ => {}
1817                    }
1818                }
1819                poll_timeout = 100; // Poll frequently while waiting for kill to take effect
1820            } else {
1821                let remaining = dl - elapsed;
1822                poll_timeout = remaining.as_millis().min(i32::MAX as u128) as i32;
1823            }
1824        }
1825
1826        if status_raw.is_none()
1827            && let Some(s) = process.wait_step()?
1828        {
1829            status_raw = Some(s);
1830        }
1831
1832        if drain.is_done() {
1833            let s = if status_raw.is_some() {
1834                status_raw.take()
1835            } else if deadline.is_none() {
1836                // C1: all pipes drained but the child is still alive, and no
1837                // deadline is set → block until it exits (intended semantics).
1838                Some(process.wait_blocking()?)
1839            } else {
1840                // C1: pipes drained with a deadline set → never block here; fall
1841                // through to the bounded `reactor.wait` below so the deadline
1842                // logic at the top of the loop kills and reaps. A later
1843                // `wait_step` reaps the child and we return from this branch.
1844                None
1845            };
1846
1847            if let Some(s) = s {
1848                for slot in drain.take_all_slots() {
1849                    if slot.token.is_some() {
1850                        reactor.del(&slot.fd)?;
1851                    }
1852                }
1853                let stdout_pending = drain.take_stdout_pending();
1854                let stderr_pending = drain.take_stderr_pending();
1855                let (stdout, stderr, output_limit_exceeded, stdout_early_exited) =
1856                    drain.into_parts_with_state();
1857                if output_limit_exceeded {
1858                    return Err(CoreError::sys(libc::EOVERFLOW, "spawn output limit"));
1859                }
1860                return Ok(Output {
1861                    pid,
1862                    status: Some(s),
1863                    stdout,
1864                    stderr,
1865                    timed_out,
1866                    stdout_early_exited,
1867                    stdout_pending,
1868                    stderr_pending,
1869                });
1870            }
1871        }
1872
1873        // Streaming mode: a paused stream (full sink queue) cannot progress
1874        // even after the child is reaped — the fd is not registered, so no
1875        // readiness event will ever arrive. Return the partial output and the
1876        // held pending chunk for the caller to flush (the blocking-path mirror
1877        // of `poll_completion`'s paused-finish branch).
1878        if status_raw.is_some() && (drain.stdout_paused() || drain.stderr_paused()) {
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,
1894                stdout_early_exited,
1895                stdout_pending,
1896                stderr_pending,
1897            });
1898        }
1899
1900        // N4: the deadline has elapsed and the child is reaped, but a wedged
1901        // pipe (a descendant inheriting the write end) keeps the drain from
1902        // closing. The absolute deadline is authoritative — return the partial
1903        // output instead of spinning forever.
1904        if timed_out && status_raw.is_some() {
1905            for slot in drain.take_all_slots() {
1906                if slot.token.is_some() {
1907                    let _ = reactor.del(&slot.fd);
1908                }
1909            }
1910            let stdout_pending = drain.take_stdout_pending();
1911            let stderr_pending = drain.take_stderr_pending();
1912            let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
1913                drain.into_parts_with_state();
1914            return Ok(Output {
1915                pid,
1916                status: status_raw,
1917                stdout,
1918                stderr,
1919                timed_out: true,
1920                stdout_early_exited,
1921                stdout_pending,
1922                stderr_pending,
1923            });
1924        }
1925
1926        // D-state: SIGKILL has been sent but the child is still unreaped after
1927        // the bound. A child stuck in uninterruptible sleep keeps the signal
1928        // pending until it leaves D-state, so no further wait can succeed —
1929        // return the partial output rather than polling forever. The pid is
1930        // not signaled again (it may be recycled once it finally exits).
1931        if let Some(sent_at) = kill_sent_at
1932            && sent_at.elapsed() >= D_STATE_REAP_BOUND
1933            && status_raw.is_none()
1934        {
1935            for slot in drain.take_all_slots() {
1936                if slot.token.is_some() {
1937                    let _ = reactor.del(&slot.fd);
1938                }
1939            }
1940            // The child is unreapable right now but will eventually leave
1941            // D-state and exit; nobody will wait on it after this give-up, so
1942            // hand it to the reaper (finding 15).
1943            orphan_child(pid);
1944            let stdout_pending = drain.take_stdout_pending();
1945            let stderr_pending = drain.take_stderr_pending();
1946            let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
1947                drain.into_parts_with_state();
1948            return Ok(Output {
1949                pid,
1950                status: None,
1951                stdout,
1952                stderr,
1953                timed_out: true,
1954                stdout_early_exited,
1955                stdout_pending,
1956                stderr_pending,
1957            });
1958        }
1959
1960        // `CancelPolicy::None`: the deadline elapsed but nothing was ever
1961        // signaled, so the child may stay wedged (pipe held by a descendant,
1962        // child unreaped) indefinitely. Give up with the partial output after
1963        // the same bound as the D-state path — otherwise this polls at 100 ms
1964        // forever (finding 14).
1965        if cancel == CancelPolicy::None
1966            && timed_out
1967            && status_raw.is_none()
1968            && deadline_passed_at.is_some_and(|passed| passed.elapsed() >= D_STATE_REAP_BOUND)
1969        {
1970            for slot in drain.take_all_slots() {
1971                if slot.token.is_some() {
1972                    let _ = reactor.del(&slot.fd);
1973                }
1974            }
1975            // The child was never signaled and may still be running; nobody
1976            // will wait on it now — hand it to the reaper (finding 15).
1977            orphan_child(pid);
1978            let stdout_pending = drain.take_stdout_pending();
1979            let stderr_pending = drain.take_stderr_pending();
1980            let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
1981                drain.into_parts_with_state();
1982            return Ok(Output {
1983                pid,
1984                status: None,
1985                stdout,
1986                stderr,
1987                timed_out: true,
1988                stdout_early_exited,
1989                stdout_pending,
1990                stderr_pending,
1991            });
1992        }
1993
1994        // Streaming backpressure: while a stream is paused its fd is not
1995        // registered (no readiness events). Keep trying to resume so a
1996        // concurrent queue consumer's drained capacity re-registers the fd,
1997        // and bound the poll so the loop cannot block forever on a paused
1998        // stream.
1999        if drain.stdout_paused() || drain.stderr_paused() {
2000            if drain.stdout_paused() {
2001                let _ = drain.resume_stdout(&mut reactor);
2002            }
2003            if drain.stderr_paused() {
2004                let _ = drain.resume_stderr(&mut reactor);
2005            }
2006            if !(0..=10).contains(&poll_timeout) {
2007                poll_timeout = 10;
2008            }
2009        }
2010
2011        let timeout = poll_timeout;
2012
2013        let mut events = Vec::new();
2014        let nevents = reactor.wait(&mut events, 64, timeout)?;
2015
2016        for ev in events.iter().take(nevents) {
2017            if drain.stdout_matches(ev.token) {
2018                if ev.readable || ev.hangup {
2019                    drain.handle_stdout_ready(&mut reactor)?;
2020                } else if ev.error {
2021                    drain.drop_stdout(&mut reactor)?;
2022                }
2023            } else if drain.stderr_matches(ev.token) {
2024                if ev.readable || ev.hangup {
2025                    drain.handle_stderr_ready(&mut reactor)?;
2026                } else if ev.error {
2027                    drain.drop_stderr(&mut reactor)?;
2028                }
2029            } else if drain.stdin_matches(ev.token) {
2030                if ev.writable {
2031                    drain.handle_stdin_writable(&mut reactor)?;
2032                } else if ev.error || ev.hangup {
2033                    drain.drop_stdin(&mut reactor)?;
2034                }
2035            }
2036        }
2037    }
2038}