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::ChunkSink;
19use crate::io::DrainState;
20use crate::io::SinkResult;
21use crate::reactor::{Reactor, Token};
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/// Orphaned sessions: pty or isolated-pipe sessions whose sweep the caller
91/// gave up on (the D-state give-up) but that may still have live members. The
92/// reaper thread keeps SIGKILLing them until `/proc` shows no live members, so
93/// the give-up — which exists because the *leader* is unreapable — can never
94/// double as "the kill failed, abandon it" and leak contained survivors
95/// (finding H2). Each entry pairs the session id with the leader's
96/// `starttime` (procfs field 22), captured at registration: the numeric sid is
97/// only trustworthy while it still names the *same* process incarnation, so a
98/// reaped-and-recycled leader pid can never be swept by a stale bare sid
99/// (finding F8 / rev6-F1 — the reaper's own liveness gate).
100static ORPHANED_SESSIONS: OnceLock<Mutex<HashSet<(pid_t, u64)>>> = OnceLock::new();
101
102/// Register a session leader's pid so the reaper thread keeps sweeping the
103/// session (SIGKILL every remaining group) until it is empty. The leader's
104/// `starttime` is captured here and verified by the reaper before every sweep:
105/// if the pid is already gone (no `/proc/<pid>/stat`) or the numeric sid has
106/// been recycled into an unrelated process, the registration is **refused** —
107/// a bare sid kill would otherwise hit an unrelated session (finding F8).
108pub(super) fn orphan_session(sid: pid_t) {
109    let Some(starttime) = crate::proc::starttime(sid) else {
110        // Leader already gone or unreadable: sweeping by this numeric sid is
111        // unsafe (recycled-pid class) and pointless (nothing to sweep if the
112        // whole session exited) — refuse.
113        return;
114    };
115    ORPHANED_SESSIONS
116        .get_or_init(|| Mutex::new(HashSet::new()))
117        .lock()
118        .unwrap()
119        .insert((sid, starttime));
120    start_reaper();
121}
122
123/// Register `pid` as orphaned (nobody will `wait` on it) and ensure the
124/// background reaper is running. No-op if the pid is already registered.
125pub(super) fn orphan_child(pid: pid_t) {
126    ORPHANED
127        .get_or_init(|| Mutex::new(HashSet::new()))
128        .lock()
129        .unwrap()
130        .insert(pid);
131    start_reaper();
132}
133
134/// Spawn (once) the background reaper thread that reaps [`ORPHANED`] pids.
135fn start_reaper() {
136    if REAPER_STARTED.load(Ordering::SeqCst) {
137        return;
138    }
139    let r = REAPER_STARTED.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst);
140    if r.is_err() {
141        return;
142    }
143    std::thread::Builder::new()
144        .name("spawn-orphan-reaper".into())
145        .spawn(reap_orphaned)
146        .map_err(|_| REAPER_STARTED.store(false, Ordering::SeqCst))
147        .ok();
148}
149
150/// Reaper body: periodically `waitpid` (non-blocking) every orphaned pid and
151/// drop it from the set once it has been reaped (or is already gone, which can
152/// only mean it was reaped elsewhere — the pid was still registered). Also
153/// keeps sweeping orphaned sessions until they are empty (finding H2).
154fn reap_orphaned() {
155    loop {
156        // ── pid arm: prune-by-reaped-only ────────────────────────────────
157        // Snapshot, then remove *exactly* the pids this pass confirmed reaped
158        // (waitpid == pid or ECHILD). Never intersect a stale snapshot: a pid
159        // registered concurrently (between snapshot and prune) survives — the
160        // F3/F4 race the old `retain` reintroduced.
161        let pids: Vec<pid_t> = ORPHANED
162            .get_or_init(|| Mutex::new(HashSet::new()))
163            .lock()
164            .unwrap()
165            .iter()
166            .copied()
167            .collect();
168        let mut reaped = Vec::new();
169        for pid in pids {
170            let mut status: libc::c_int = 0;
171            let r = unsafe { waitpid(pid, &mut status, libc::WNOHANG) };
172            if r == pid
173                || (r < 0 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ECHILD))
174            {
175                reaped.push(pid); // confirmed reaped or gone — prune exactly this pid
176            }
177        }
178        if !reaped.is_empty()
179            && let Some(set) = ORPHANED.get()
180            && let Ok(mut guard) = set.lock()
181        {
182            prune_reaped_by_reaped_only(&mut guard, &reaped);
183        }
184        // ── session arm: leader-liveness-gated sweep ─────────────────────
185        // H2 + rev6-F1: keep SIGKILLing every remaining group until /proc
186        // shows no live members. The sweep excludes zombies, so a
187        // killed-but-unreaped member does not hold the registration open; a
188        // genuinely D-state member keeps SIGKILL pending until it wakes and
189        // the sweep converges then. Each iteration first verifies the numeric
190        // sid still names the *registered leader incarnation* (starttime): if
191        // the leader is gone or the pid was recycled, the sid is no longer a
192        // safe handle — drop it WITHOUT killing (a bare-sid sweep would hit an
193        // unrelated recycled session, finding F8).
194        if let Some(sessions) = ORPHANED_SESSIONS.get() {
195            let sids: Vec<(pid_t, u64)> = sessions.lock().unwrap().iter().copied().collect();
196            let mut converged = Vec::new();
197            for (sid, starttime) in sids {
198                if crate::proc::starttime(sid) != Some(starttime) {
199                    // Leader gone or recycled — never sweep by this bare sid.
200                    converged.push((sid, starttime));
201                    continue;
202                }
203                if session_sweep(sid).unwrap_or(false) {
204                    converged.push((sid, starttime));
205                }
206            }
207            if !converged.is_empty()
208                && let Ok(mut guard) = sessions.lock()
209            {
210                for entry in converged {
211                    guard.remove(&entry);
212                }
213            }
214        }
215        std::thread::sleep(Duration::from_millis(250));
216    }
217}
218
219/// Remove from `set` exactly the pids confirmed reaped this pass. Pure seam
220/// (F3/F4 regression): the reaper's pid arm snapshots the orphan set, then
221/// prunes by this list — a pid registered *after* the snapshot but before the
222/// prune is left untouched.
223pub(super) fn prune_reaped_by_reaped_only(
224    set: &mut std::collections::HashSet<pid_t>,
225    reaped: &[pid_t],
226) {
227    for pid in reaped {
228        set.remove(pid);
229    }
230}
231
232/// Test-only: number of currently registered orphaned sessions.
233#[cfg(test)]
234pub(super) fn orphaned_sessions_len() -> usize {
235    ORPHANED_SESSIONS
236        .get()
237        .map(|s| s.lock().unwrap().len())
238        .unwrap_or(0)
239}
240
241/// Policy for handling process cancellation or timeouts.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
243pub enum CancelPolicy {
244    /// Do nothing on cancellation; let the process run to completion.
245    #[default]
246    None,
247    /// Send SIGTERM, then SIGKILL after a grace period.
248    Graceful,
249    /// Send SIGKILL immediately.
250    Kill,
251}
252
253/// Policy for what *natural* completion (the leader exiting on its own) does
254/// with a still-live isolated/pty session.
255///
256/// A background member that keeps the pty slave (or a contained descendant
257/// holding a captured pipe) open prevents the master EOF that the historical
258/// natural gate waited on — without a sweep the job would hang forever (the
259/// A4-3 seam). This policy tells Core whether the sweep is the completion
260/// trigger (default, kill-totality) or whether members may survive the leader.
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
262pub enum SessionExitPolicy {
263    /// Sweep the contained session once the leader is reaped: SIGKILL live
264    /// session members and hold completion until the session is empty
265    /// (kill-totality, the historic containment guarantee). The sweep is the
266    /// *trigger*, not a side-effect of EOF — a slave-holding member is killed
267    /// and then the master can EOF. Cannot be combined with
268    /// [`CancelPolicy::None`] (which opts out of all signaling).
269    #[default]
270    Sweep,
271    /// Report completion on leader-reap without signaling the session: a
272    /// background member (e.g. `nohup sleep &`) survives the leader, matching
273    /// plain POSIX shell semantics. The leader-reap remains the completion
274    /// gate so the slave-holding-member hang is still impossible.
275    LetMembersSurvive,
276}
277
278/// Process group and session configuration.
279#[derive(Debug, Clone, Copy, Default)]
280pub struct ProcessGroup {
281    /// Join an existing process group leader.
282    pub leader: Option<pid_t>,
283    /// Create a new session (`setsid`).
284    pub isolated: bool,
285}
286
287impl ProcessGroup {
288    /// Create a new process group configuration.
289    pub fn new(leader: Option<pid_t>, isolated: bool) -> Self {
290        Self { leader, isolated }
291    }
292}
293
294#[inline(always)]
295fn errno() -> i32 {
296    std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
297}
298
299/// Relocate `fd` to the lowest available descriptor `>= 3`, closing the
300/// original. Guards against `pipe2` handing back fds 0/1/2 when the daemon
301/// runs with stdio closed: a pipe on 0/1/2 would collide with the child's
302/// `dup2(…, 0/1/2)` setup (clobbering a still-needed end) and with the
303/// stdio-tracking in `close_child_fds_for_policy`.
304fn relocate_above_stdio(fd: RawFd, op: &'static str) -> Result<RawFd, CoreError> {
305    if fd >= 3 {
306        return Ok(fd);
307    }
308    let new = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 3) };
309    syscall_ret(new, op)?;
310    unsafe {
311        libc::close(fd);
312    }
313    Ok(new)
314}
315
316/// Creates a pipe with O_CLOEXEC, relocated above stdio. Both ends stay
317/// blocking; the parent-facing ends are flipped to O_NONBLOCK by
318/// [`DrainState`] after spawn so the child never inherits a non-blocking
319/// stdio (which would silently truncate child output on `EAGAIN`).
320/// Invariants: FDs returned are strictly >= 3 and will close automatically on drop.
321#[inline(always)]
322fn make_pipe() -> Result<(Fd, Fd), CoreError> {
323    let mut fds = [0; 2];
324    let r = unsafe { pipe2(fds.as_mut_ptr(), O_CLOEXEC) };
325    syscall_ret(r, "pipe2")?;
326    let r0 = match relocate_above_stdio(fds[0], "pipe2:relocate") {
327        Ok(fd) => fd,
328        Err(e) => {
329            // fds[0] is still open when its relocation fails; close to avoid
330            // leaking under fd pressure (EMFILE).
331            unsafe {
332                libc::close(fds[0]);
333            }
334            return Err(e);
335        }
336    };
337    let r1 = match relocate_above_stdio(fds[1], "pipe2:relocate") {
338        Ok(fd) => fd,
339        Err(e) => {
340            // fds[1] is still open (relocation failed), and r0 was relocated
341            // above — both would leak on this error path.
342            unsafe {
343                libc::close(r0);
344                libc::close(fds[1]);
345            }
346            return Err(e);
347        }
348    };
349    Ok((Fd::new(r0, "pipe2")?, Fd::new(r1, "pipe2")?))
350}
351
352fn make_cloexec_pipe() -> Result<(RawFd, RawFd), CoreError> {
353    let mut fds = [0; 2];
354    let r = unsafe { pipe2(fds.as_mut_ptr(), O_CLOEXEC) };
355    syscall_ret(r, "pipe2")?;
356    let r0 = match relocate_above_stdio(fds[0], "pipe2:relocate") {
357        Ok(fd) => fd,
358        Err(e) => {
359            unsafe {
360                libc::close(fds[0]);
361            }
362            return Err(e);
363        }
364    };
365    let r1 = match relocate_above_stdio(fds[1], "pipe2:relocate") {
366        Ok(fd) => fd,
367        Err(e) => {
368            unsafe {
369                libc::close(r0);
370                libc::close(fds[1]);
371            }
372            return Err(e);
373        }
374    };
375    Ok((r0, r1))
376}
377
378/// Open a new pseudo-terminal and return `(master, slave)`, both relocated
379/// above stdio with `O_CLOEXEC`.
380///
381/// The master is drained as the child's single merged stdout+stderr stream;
382/// the slave is dup2'd to the child's fd 0/1/2 in the child setup. Both are
383/// `O_NOCTTY` so neither side accidentally becomes a controlling terminal of
384/// the daemon (only the child claims it via `TIOCSCTTY`).
385///
386/// The pair is returned to the caller so a spawn can be configured *before*
387/// the child execs: apply the initial window with [`pty_window`], derive the
388/// child's `LINES`/`COLUMNS` env from that read-back, then hand ownership to
389/// [`SpawnOptionsBuilder::pty_with`]. Core re-takes ownership inside `Pipes`:
390/// from the moment the pair enters the spawn options, Core owns both
391/// descriptors and is responsible for their cleanup on every success and
392/// failure path — there is no ambiguous "does the caller still own this fd?"
393/// state.
394pub fn make_pty() -> Result<(Fd, Fd), CoreError> {
395    let master = unsafe {
396        libc::open(
397            c"/dev/ptmx".as_ptr(),
398            libc::O_RDWR | libc::O_NOCTTY | libc::O_CLOEXEC,
399        )
400    };
401    syscall_ret(master, "open /dev/ptmx")?;
402    let master = match relocate_above_stdio(master, "ptmx:relocate") {
403        Ok(fd) => fd,
404        Err(e) => {
405            unsafe {
406                libc::close(master);
407            }
408            return Err(e);
409        }
410    };
411    let result = (|| -> Result<RawFd, CoreError> {
412        // `grantpt` on Linux devpts is a no-op success, but keep it for
413        // portability; `unlockpt` is required before the slave can be opened.
414        let r = unsafe { libc::grantpt(master) };
415        syscall_ret(r, "grantpt")?;
416        let r = unsafe { libc::unlockpt(master) };
417        syscall_ret(r, "unlockpt")?;
418        let mut name = [0 as libc::c_char; 4096];
419        let r = unsafe { libc::ptsname_r(master, name.as_mut_ptr(), name.len()) };
420        if r != 0 {
421            return Err(CoreError::sys(r, "ptsname_r"));
422        }
423        let slave = unsafe {
424            libc::open(
425                name.as_ptr(),
426                libc::O_RDWR | libc::O_NOCTTY | libc::O_CLOEXEC,
427            )
428        };
429        syscall_ret(slave, "open pty slave")?;
430        Ok(slave)
431    })();
432    match result {
433        Ok(slave) => {
434            let slave = match relocate_above_stdio(slave, "pty slave:relocate") {
435                Ok(fd) => fd,
436                Err(e) => {
437                    unsafe {
438                        libc::close(slave);
439                        libc::close(master);
440                    }
441                    return Err(e);
442                }
443            };
444            Ok((Fd::new(master, "pty master")?, Fd::new(slave, "pty slave")?))
445        }
446        Err(e) => {
447            unsafe {
448                libc::close(master);
449            }
450            Err(e)
451        }
452    }
453}
454
455/// Apply an initial window size to a pty master *before* the child execs and
456/// read back the actual `winsize` the kernel holds.
457///
458/// This is the single source of truth for the pty's starting geometry: callers
459/// that need `LINES`/`COLUMNS` in the child environment must derive them from
460/// the **returned** `(rows, cols)` — the `TIOCGWINSZ` read-back — not from the
461/// values they passed in. Two writers of the same fact (the wire dims *and* a
462/// separate `TIOCSWINSZ`) can drift the moment one call site changes; the
463/// read-back keeps the env provably consistent with what the kernel/pty layer
464/// believes.
465///
466/// ### Errors
467/// - `EINVAL`: `rows` or `cols` is zero.
468/// - `ENOTTY`: `master` is not a terminal.
469pub fn pty_window(master: &Fd, rows: u16, cols: u16) -> Result<(u16, u16), CoreError> {
470    if rows == 0 || cols == 0 {
471        return Err(CoreError::sys(
472            libc::EINVAL,
473            "pty_window: rows and cols must be non-zero",
474        ));
475    }
476    let ws = libc::winsize {
477        ws_row: rows,
478        ws_col: cols,
479        ws_xpixel: 0,
480        ws_ypixel: 0,
481    };
482    let r = unsafe { libc::ioctl(master.raw(), libc::TIOCSWINSZ as libc::Ioctl, &ws) };
483    syscall_ret(r, "TIOCSWINSZ")?;
484    let mut got: libc::winsize = unsafe { std::mem::zeroed() };
485    let r = unsafe { libc::ioctl(master.raw(), libc::TIOCGWINSZ as libc::Ioctl, &mut got) };
486    syscall_ret(r, "TIOCGWINSZ")?;
487    Ok((got.ws_row, got.ws_col))
488}
489
490struct Pipes {
491    stdin_r: Option<Fd>,
492    stdin_w: Option<Fd>,
493    stdout_r: Option<Fd>,
494    stdout_w: Option<Fd>,
495    stderr_r: Option<Fd>,
496    stderr_w: Option<Fd>,
497    /// Pty mode: the master end, drained as the child's single merged stdout
498    /// stream (parent side). `O_CLOEXEC`, relocated above stdio.
499    pty_master: Option<Fd>,
500    /// Pty mode: the slave end, dup2'd to the child's fd 0/1/2 and made its
501    /// controlling terminal. `O_CLOEXEC` so the original (≥3) closes on exec
502    /// after the dup2s.
503    pty_slave: Option<Fd>,
504}
505
506impl Pipes {
507    fn new(
508        in_buf: Option<&[u8]>,
509        out: bool,
510        err: bool,
511        pty: bool,
512        pty_fds: Option<(Fd, Fd)>,
513    ) -> Result<Self, CoreError> {
514        if pty {
515            let (master, slave) = match pty_fds {
516                // Caller-supplied pair (see `SpawnOptionsBuilder::pty_with`):
517                // Core takes ownership here and closes both on every
518                // success/failure path.
519                Some(pair) => pair,
520                None => make_pty()?,
521            };
522            return Ok(Self {
523                stdin_r: None,
524                stdin_w: None,
525                stdout_r: None,
526                stdout_w: None,
527                stderr_r: None,
528                stderr_w: None,
529                pty_master: Some(master),
530                pty_slave: Some(slave),
531            });
532        }
533        let (stdin_r, stdin_w) = if in_buf.is_some() {
534            let (r, w) = make_pipe()?;
535            (Some(r), Some(w))
536        } else {
537            (None, None)
538        };
539
540        let (stdout_r, stdout_w) = if out {
541            let (r, w) = make_pipe()?;
542            (Some(r), Some(w))
543        } else {
544            (None, None)
545        };
546
547        let (stderr_r, stderr_w) = if err {
548            let (r, w) = make_pipe()?;
549            (Some(r), Some(w))
550        } else {
551            (None, None)
552        };
553
554        Ok(Self {
555            stdin_r,
556            stdin_w,
557            stdout_r,
558            stdout_w,
559            stderr_r,
560            stderr_w,
561            pty_master: None,
562            pty_slave: None,
563        })
564    }
565
566    #[inline(always)]
567    fn close_all(&mut self) {
568        self.stdin_r.take();
569        self.stdin_w.take();
570        self.stdout_r.take();
571        self.stdout_w.take();
572        self.stderr_r.take();
573        self.stderr_w.take();
574        self.pty_master.take();
575        self.pty_slave.take();
576    }
577}
578
579/// Represents the termination status of a process.
580#[derive(Debug, PartialEq, Eq)]
581pub enum ExitStatus {
582    /// Process exited normally with the specified code.
583    Exited(i32),
584    /// Process was terminated by a signal.
585    Signaled(i32),
586}
587
588/// Explicit process spawning backend.
589#[derive(Debug, Clone, Copy, PartialEq, Eq)]
590pub enum SpawnBackend {
591    /// Force the use of `posix_spawn`.
592    PosixSpawn,
593    /// Force the use of `fork`/`exec`.
594    ///
595    /// The fork backend supports explicit [`SpawnFdPolicy`] handling before
596    /// `execve`.
597    Fork,
598    /// Force the use of `vfork`/`exec`.
599    ///
600    /// `vfork` shares the parent's address space with the child until it
601    /// `execve`s (or `_exit`s), so it avoids the page-table work of `fork`.
602    /// The child runs only async-signal-safe setup before `execve`, and the
603    /// calling thread is blocked until the child execs. Safe for the child
604    /// because the Linux `vfork` child inherits a *copy* of the descriptor
605    /// table, so [`SpawnFdPolicy`] handling works as with [`SpawnBackend::Fork`].
606    ///
607    /// Use only when the shared-address-space semantics are understood:
608    /// the child must never return from the spawn entry point, and a bug in the
609    /// child setup can corrupt the parent's memory.
610    Vfork,
611    /// Force the use of `clone3(2)`/`exec` (kernel 5.3+).
612    ///
613    /// `clone3` with process flags creates a child with copy-on-write memory
614    /// and a copied descriptor table, like [`SpawnBackend::Fork`], but lets the
615    /// caller control clone flags directly. Supported by the same child setup
616    /// as the fork backend. Returns `ENOSYS` on kernels without `clone3`.
617    Clone3,
618    /// Force the use of `clone3(2)` with `CLONE_PIDFD` + `exec` (kernel 5.3+).
619    ///
620    /// Identical to [`SpawnBackend::Clone3`], but the kernel additionally hands
621    /// the parent a pidfd for the child. The resulting [`Process`] carries that
622    /// pidfd: signaling uses `pidfd_send_signal` (immune to pid reuse), and
623    /// exit detection `poll`s the pidfd instead of polling `waitpid`. Returns
624    /// `ENOSYS` on kernels without `clone3`.
625    Clone3Pidfd,
626}
627
628/// Explicit file-descriptor inheritance policy for spawned children.
629#[derive(Debug, Clone, PartialEq, Eq, Default)]
630pub enum SpawnFdPolicy {
631    /// Inherit descriptors according to their existing `FD_CLOEXEC` flags.
632    #[default]
633    CloexecOnly,
634    /// For the fork backend, close every descriptor >= 3 before `execve`,
635    /// except Core-required pipe descriptors.
636    CloseFrom3,
637    /// For the fork backend, close every descriptor >= 3 before `execve`,
638    /// except Core-required pipe descriptors and the listed descriptors.
639    ///
640    /// Core does not close allowlisted descriptors, but their existing
641    /// `FD_CLOEXEC` state still applies. Callers that want an allowlisted
642    /// descriptor to survive `execve` must clear `FD_CLOEXEC` before spawning.
643    Allowlist(Vec<RawFd>),
644}
645
646#[inline(always)]
647fn decode_status(status: i32) -> ExitStatus {
648    if WIFEXITED(status) {
649        ExitStatus::Exited(WEXITSTATUS(status))
650    } else if WIFSIGNALED(status) {
651        ExitStatus::Signaled(WTERMSIG(status))
652    } else {
653        ExitStatus::Exited(-1)
654    }
655}
656
657/// A handle to a spawned process.
658///
659/// ### Fork Safety
660/// The process handle contains a PID. After a `fork`, the child process will
661/// have a copy of this PID, but it refers to the same original process.
662/// Calling `wait` or `kill` from the child may lead to confusing results
663/// if multiple processes are managing the same PID.
664///
665/// When the process was spawned by [`SpawnBackend::Clone3Pidfd`], the handle
666/// additionally owns the child's pidfd. Signaling then uses
667/// `pidfd_send_signal`, which cannot race with pid reuse, and exit detection
668/// `poll`s the pidfd. The pidfd is closed when the handle is dropped.
669pub struct Process {
670    pid: pid_t,
671    pidfd: Option<RawFd>,
672}
673
674impl Process {
675    /// Create a handle for an existing PID (no pidfd).
676    pub fn new(pid: pid_t) -> Self {
677        Self { pid, pidfd: None }
678    }
679
680    /// Create a handle for an existing PID that also owns its pidfd.
681    pub(crate) fn with_pidfd(pid: pid_t, pidfd: RawFd) -> Self {
682        Self {
683            pid,
684            pidfd: Some(pidfd),
685        }
686    }
687
688    /// Return the process ID.
689    pub fn pid(&self) -> pid_t {
690        self.pid
691    }
692
693    /// Return the pidfd owned by this handle, if any.
694    pub fn pidfd(&self) -> Option<RawFd> {
695        self.pidfd
696    }
697
698    /// Perform a non-blocking wait for process termination.
699    ///
700    /// When the handle owns a pidfd, the wait first `poll`s the pidfd (which
701    /// becomes readable exactly when the child exits) and then reaps with
702    /// `waitpid`, avoiding the `ECHILD`-race of polling `waitpid` directly.
703    ///
704    /// ### Errors
705    /// - `ECHILD`: The process does not exist or is not a child of the caller.
706    /// - `EINTR`: The call was interrupted by a signal (handled internally).
707    pub fn wait_step(&self) -> Result<Option<ExitStatus>, CoreError> {
708        if let Some(pidfd) = self.pidfd {
709            return wait_step_pidfd(pidfd, self.pid);
710        }
711        loop {
712            let mut status = 0;
713            let r = unsafe { waitpid(self.pid, &mut status, libc::WNOHANG) };
714            if r == 0 {
715                return Ok(None);
716            }
717            if r < 0 {
718                let e = errno();
719                if e == libc::EINTR {
720                    continue;
721                }
722                return Err(CoreError::sys(e, "waitpid_step"));
723            }
724            return Ok(Some(decode_status(status)));
725        }
726    }
727
728    /// Block until the process terminates.
729    ///
730    /// ### Errors
731    /// - `ECHILD`: The process does not exist or is not a child of the caller.
732    pub fn wait_blocking(&self) -> Result<ExitStatus, CoreError> {
733        loop {
734            let mut status = 0;
735            let r = unsafe { waitpid(self.pid, &mut status, 0) };
736            if r < 0 {
737                let e = errno();
738                if e == libc::EINTR {
739                    continue;
740                }
741                return Err(CoreError::sys(e, "waitpid_blocking"));
742            }
743            return Ok(decode_status(status));
744        }
745    }
746
747    /// Send a signal to the process.
748    ///
749    /// When the handle owns a pidfd, the signal is delivered with
750    /// `pidfd_send_signal`, which cannot target a recycled pid; on kernels
751    /// without it (`ENOSYS`, kernel < 5.1) it falls back to `kill`.
752    ///
753    /// ### Errors
754    /// - `EINVAL`: Invalid signal number, or a non-positive pid (pid `0`
755    ///   would signal the caller's own process group). With a pidfd,
756    ///   `pidfd_send_signal` returns `EINVAL` for an invalid signal and this
757    ///   is reported, not downgraded to a `kill` fallback.
758    /// - `EPERM`: The caller does not have permission to send the signal.
759    /// - `ESRCH`: The process does not exist.
760    pub fn kill(&self, sig: i32) -> Result<(), CoreError> {
761        if let Some(pidfd) = self.pidfd {
762            let r = unsafe {
763                libc::syscall(
764                    SYS_PIDFD_SEND_SIGNAL,
765                    pidfd,
766                    sig,
767                    std::ptr::null_mut::<libc::siginfo_t>(),
768                    0,
769                )
770            };
771            if r < 0 {
772                let e = errno();
773                if e == libc::ESRCH {
774                    return Ok(());
775                }
776                // `pidfd_send_signal` returns EINVAL for an invalid signal
777                // number or an unsupported flag — falling back to `kill` on
778                // EINVAL would change semantics (e.g. signal 0 becomes an
779                // existence check). Only a kernel that lacks the syscall
780                // entirely (ENOSYS, pre-5.1) warrants the `kill` fallback.
781                if e != libc::ENOSYS {
782                    return Err(CoreError::sys(e, "pidfd_send_signal"));
783                }
784                // Kernel lacks pidfd_send_signal; fall through to kill.
785            } else {
786                return Ok(());
787            }
788        }
789        if self.pid <= 0 {
790            return Err(CoreError::sys(libc::EINVAL, "kill: invalid pid"));
791        }
792        let r = unsafe { libc::kill(self.pid, sig) };
793        if r < 0 {
794            let e = errno();
795            if e == libc::ESRCH {
796                return Ok(());
797            }
798            syscall_ret(-1, "kill")?;
799        }
800        Ok(())
801    }
802
803    /// Signal the process group whose id equals [`Self::pid`] — valid only
804    /// when the process is its own group/session leader. For a child placed
805    /// into a custom leader's group use [`Self::kill_group`].
806    ///
807    /// ### Errors
808    /// Same as [`Self::kill`].
809    pub fn kill_pgroup(&self, sig: i32) -> Result<(), CoreError> {
810        self.kill_group(self.pid, sig)
811    }
812
813    /// Send a signal to an explicit process group.
814    ///
815    /// The pgid must be the child's actual group (its own pid after `setsid`,
816    /// or the configured leader's id after `setpgid`), never guessed from the
817    /// pid, and never `0` or negative — `kill(-0)` would signal the caller's
818    /// own process group.
819    ///
820    /// ### Errors
821    /// Same as [`Self::kill`], plus `EINVAL` for a non-positive pgid.
822    pub fn kill_group(&self, pgid: pid_t, sig: i32) -> Result<(), CoreError> {
823        if pgid <= 0 {
824            return Err(CoreError::sys(libc::EINVAL, "kill_group: invalid pgid"));
825        }
826        let r = unsafe { libc::kill(-pgid, sig) };
827        if r < 0 {
828            let e = errno();
829            if e == libc::ESRCH {
830                return Ok(());
831            }
832            syscall_ret(-1, "kill_group")?;
833        }
834        Ok(())
835    }
836}
837
838impl Drop for Process {
839    fn drop(&mut self) {
840        if let Some(pidfd) = self.pidfd.take() {
841            unsafe {
842                libc::close(pidfd);
843            }
844        }
845    }
846}
847
848/// Non-blocking exit wait using a pidfd: `poll(2)` on the pidfd becomes
849/// readable exactly when the child exits, and reaping still uses `waitpid`
850/// (our own child cannot be pid-recycled while it is unreaped). Returns
851/// `Ok(None)` while the child is running or was already reaped.
852fn wait_step_pidfd(pidfd: RawFd, pid: pid_t) -> Result<Option<ExitStatus>, CoreError> {
853    let mut pfd = libc::pollfd {
854        fd: pidfd,
855        events: libc::POLLIN,
856        revents: 0,
857    };
858    loop {
859        let r = unsafe { libc::poll(&mut pfd, 1, 0) };
860        if r < 0 {
861            let e = errno();
862            if e == libc::EINTR {
863                continue;
864            }
865            return Err(CoreError::sys(e, "poll(pidfd)"));
866        }
867        break;
868    }
869    if pfd.revents & libc::POLLIN == 0 {
870        return Ok(None);
871    }
872    loop {
873        let mut status = 0;
874        let r = unsafe { waitpid(pid, &mut status, libc::WNOHANG) };
875        if r == pid {
876            return Ok(Some(decode_status(status)));
877        }
878        if r < 0 {
879            let e = errno();
880            if e == libc::EINTR {
881                continue;
882            }
883            if e == libc::ECHILD {
884                // Reaped elsewhere; the pidfd stays readable.
885                return Ok(None);
886            }
887            return Err(CoreError::sys(e, "waitpid(pidfd step)"));
888        }
889        // r == 0: readiness raced with a concurrent reap; not running now.
890        return Ok(None);
891    }
892}
893
894/// Configuration options for spawning a new process.
895///
896/// Move-only: when `pty_fds` is `Some` the struct owns a pty pair (raw OS
897/// descriptors), so it is not `Clone` — duplicating the builder would duplicate
898/// ownership of fds that cannot be duplicated.
899pub struct SpawnOptions {
900    ctx: ExecContext,
901    stdin: Option<Box<[u8]>>,
902    capture_stdout: bool,
903    capture_stderr: bool,
904    wait: bool,
905    pgroup: ProcessGroup,
906    session_containment: bool,
907    max_output: usize,
908    timeout_ms: Option<u32>,
909    kill_grace_ms: u32,
910    cancel: CancelPolicy,
911    backend: SpawnBackend,
912    fd_policy: SpawnFdPolicy,
913    early_exit: Option<fn(&[u8]) -> bool>,
914    /// Optional streaming chunk observer: every retained output chunk is
915    /// forwarded here as it is read (`is_stdout`, bytes) instead of being
916    /// accumulated for the completion [`Output`]. Return [`SinkResult::Pause`]
917    /// to stop draining (the chunk is retained and re-delivered on resume);
918    /// bytes are never dropped on this path and the read loop never blocks.
919    /// Ignored when the stream is not captured.
920    chunk_sink: Option<ChunkSink>,
921    /// Spawn the child on a pseudo-terminal instead of captured pipes: the
922    /// slave becomes the child's controlling terminal (setsid + `TIOCSCTTY`,
923    /// dup2'd to fd 0/1/2) and the master is drained as a single merged
924    /// stdout+stderr stream. Requires an isolated process group (a session is
925    /// needed before `TIOCSCTTY`) and is unsupported on the posix_spawn
926    /// backend (no child setup step). The master is exposed to the caller's
927    /// drain for reads and to [`RunningProcess::resize_pty`] for `TIOCSWINSZ`.
928    ///
929    /// Termios is **not** configured: the slave keeps the kernel-default
930    /// cooked line discipline (`ISIG|ICANON|ECHO|IXON` on, `IUTF8` off). The
931    /// caller owns termios (tcsetattr on the slave) — Core is no-policy.
932    /// Interactive callers that keep cooked mode must not locally echo
933    /// (the kernel already does); a raw-mode caller is responsible for its
934    /// own echo and signal mapping.
935    pty: bool,
936    /// Preexisting pty pair supplied by the caller (via
937    /// [`SpawnOptionsBuilder::pty_with`]): Core takes ownership of both
938    /// descriptors and is responsible for their cleanup on every success and
939    /// failure path. `Some` implies `pty == true`; the pair is used instead of
940    /// calling [`make_pty`] internally, so the caller can apply an initial
941    /// window (`TIOCSWINSZ`) and read it back (`TIOCGWINSZ`) before the child
942    /// execs.
943    pty_fds: Option<(Fd, Fd)>,
944    /// Opt-in `PR_SET_PDEATHSIG`: the signal the child receives when the
945    /// **parent thread that created it** exits (not the process — see
946    /// `docs/ARCHITECTURE.md`). Leader-only: it reaches the spawned leader's
947    /// whole process, not session members in other process groups. The child
948    /// arms it before any other setup and verifies `getppid()` still equals the
949    /// expected parent, closing the fork→prctl race. None (default): no
950    /// parent-death signal.
951    pdeath_signal: Option<i32>,
952    /// Natural-exit policy for a contained session (see [`SessionExitPolicy`]).
953    /// Defaults to [`SessionExitPolicy::Sweep`].
954    session_exit: SessionExitPolicy,
955}
956
957impl SpawnOptions {
958    /// Create a new builder for process spawning.
959    pub fn builder(argv: Vec<String>, backend: SpawnBackend) -> SpawnOptionsBuilder {
960        SpawnOptionsBuilder::new(argv, backend)
961    }
962
963    /// Execute the process according to the options and block until completion.
964    pub fn run(self) -> Result<Output, CoreError> {
965        spawn(self)
966    }
967}
968
969/// Builder for [`SpawnOptions`].
970///
971/// Move-only when [`SpawnOptionsBuilder::pty_with`] has been called: the
972/// builder then owns a pty pair, so it is not `Clone` (see [`SpawnOptions`]).
973pub struct SpawnOptionsBuilder {
974    argv: Vec<String>,
975    env: Option<Vec<String>>,
976    cwd: Option<String>,
977    stdin: Option<Box<[u8]>>,
978    capture_stdout: bool,
979    capture_stderr: bool,
980    wait: bool,
981    pgroup: ProcessGroup,
982    session_containment: bool,
983    max_output: usize,
984    timeout_ms: Option<u32>,
985    kill_grace_ms: u32,
986    cancel: CancelPolicy,
987    backend: SpawnBackend,
988    fd_policy: SpawnFdPolicy,
989    early_exit: Option<fn(&[u8]) -> bool>,
990    chunk_sink: Option<ChunkSink>,
991    pty: bool,
992    pty_fds: Option<(Fd, Fd)>,
993    pdeath_signal: Option<i32>,
994    session_exit: SessionExitPolicy,
995}
996
997impl SpawnOptionsBuilder {
998    /// Create a new builder with the specified argument vector.
999    pub fn new(argv: Vec<String>, backend: SpawnBackend) -> Self {
1000        Self {
1001            argv,
1002            env: None,
1003            cwd: None,
1004            stdin: None,
1005            capture_stdout: false,
1006            capture_stderr: false,
1007            wait: true,
1008            pgroup: ProcessGroup::default(),
1009            session_containment: false,
1010            max_output: 1024 * 1024,
1011            timeout_ms: None,
1012            kill_grace_ms: 2000,
1013            cancel: CancelPolicy::Kill,
1014            backend,
1015            fd_policy: SpawnFdPolicy::default(),
1016            early_exit: None,
1017            chunk_sink: None,
1018            pty: false,
1019            pty_fds: None,
1020            pdeath_signal: None,
1021            session_exit: SessionExitPolicy::Sweep,
1022        }
1023    }
1024
1025    /// Set environment variables.
1026    pub fn env(mut self, env: Vec<String>) -> Self {
1027        self.env = Some(env);
1028        self
1029    }
1030
1031    /// Set the working directory.
1032    pub fn cwd(mut self, cwd: String) -> Self {
1033        self.cwd = Some(cwd);
1034        self
1035    }
1036
1037    /// Provide data to be written to the child's stdin.
1038    pub fn stdin(mut self, data: impl Into<Box<[u8]>>) -> Self {
1039        self.stdin = Some(data.into());
1040        self
1041    }
1042
1043    /// Enable stdout capture.
1044    pub fn capture_stdout(mut self) -> Self {
1045        self.capture_stdout = true;
1046        self
1047    }
1048
1049    /// Enable stderr capture.
1050    pub fn capture_stderr(mut self) -> Self {
1051        self.capture_stderr = true;
1052        self
1053    }
1054
1055    /// Set whether to wait for the process to terminate (default: true).
1056    pub fn wait(mut self, wait: bool) -> Self {
1057        self.wait = wait;
1058        self
1059    }
1060
1061    /// Set process group and isolation policy.
1062    pub fn pgroup(mut self, pgroup: ProcessGroup) -> Self {
1063        self.pgroup = pgroup;
1064        self
1065    }
1066
1067    /// Contain the child inside the process group/session it is placed into.
1068    ///
1069    /// A seccomp filter installed in the child (after the daemon's own
1070    /// `setsid`/`setpgid`, before `execve`) denies `setsid`, `setpgid`,
1071    /// `setpgrp`, `unshare`, and `setns`. Because filters are inherited
1072    /// across `fork` and `execve` and can only be tightened, never loosened,
1073    /// the child and every descendant are locked into the group/session —
1074    /// making `kill_group` (timeout/cancel deactivation) total even against a
1075    /// hostile root child that tries to escape by daemonizing or changing its
1076    /// process group. Requires an isolated process group
1077    /// ([`ProcessGroup::new(None, true)`](ProcessGroup::new)); rejected on
1078    /// [`SpawnBackend::PosixSpawn`](SpawnBackend::PosixSpawn), which has no
1079    /// child setup step.
1080    pub fn session_containment(mut self) -> Self {
1081        self.session_containment = true;
1082        self
1083    }
1084
1085    /// Set the combined stdout+stderr output buffer size (default: 1MB).
1086    ///
1087    /// If captured output exceeds this limit, spawn drains the child pipes to
1088    /// completion and returns `EOVERFLOW`.
1089    pub fn max_output(mut self, max: usize) -> Self {
1090        self.max_output = max;
1091        self
1092    }
1093
1094    /// Set the execution timeout in milliseconds.
1095    pub fn timeout_ms(mut self, ms: u32) -> Self {
1096        self.timeout_ms = Some(ms);
1097        self
1098    }
1099
1100    /// Set the grace period before SIGKILL (default: 2s).
1101    pub fn kill_grace_ms(mut self, ms: u32) -> Self {
1102        self.kill_grace_ms = ms;
1103        self
1104    }
1105
1106    /// Set the cancellation policy (default: Kill).
1107    pub fn cancel(mut self, policy: CancelPolicy) -> Self {
1108        self.cancel = policy;
1109        self
1110    }
1111
1112    /// Set the child file-descriptor inheritance policy.
1113    pub fn fd_policy(mut self, policy: SpawnFdPolicy) -> Self {
1114        self.fd_policy = policy;
1115        self
1116    }
1117
1118    /// Set an early exit callback.
1119    pub fn early_exit(mut self, callback: fn(&[u8]) -> bool) -> Self {
1120        self.early_exit = Some(callback);
1121        self
1122    }
1123
1124    /// Enable streaming drain: forward every retained output chunk to `sink`
1125    /// as it is read instead of accumulating it for the completion [`Output`].
1126    ///
1127    /// The sink returns [`SinkResult::Pause`] when its bounded queue is full;
1128    /// the drain then stops reading the child (kernel backpressure applies)
1129    /// without dropping the held chunk and without blocking the reactor.
1130    /// Resume via the managed-process or drain resume methods once the queue
1131    /// drains. When a sink is set, `max_output` no longer truncates: bytes
1132    /// are never dropped on the streaming path.
1133    pub fn chunk_sink<F>(mut self, sink: F) -> Self
1134    where
1135        F: Fn(bool, &[u8]) -> SinkResult + Send + Sync + 'static,
1136    {
1137        self.chunk_sink = Some(Arc::new(sink));
1138        self
1139    }
1140
1141    /// Spawn the child on a pseudo-terminal (see [`SpawnOptions::pty`]).
1142    ///
1143    /// Mutually exclusive with pipe capture: the slave replaces
1144    /// `capture_stdout`/`capture_stderr`/`stdin` as the child's stdio, and the
1145    /// master replaces the stdout pipe on the drain (single merged stream).
1146    ///
1147    /// Core creates the pty pair internally. To pre-configure the pty window
1148    /// before the child execs (and derive the child's terminal env from the
1149    /// read-back), use [`SpawnOptionsBuilder::pty_with`] instead — it takes a
1150    /// caller-created pair and is move-only.
1151    pub fn pty(mut self) -> Self {
1152        self.pty = true;
1153        self
1154    }
1155
1156    /// Spawn the child on a pseudo-terminal using a **caller-created** pty
1157    /// pair, whose initial window the caller already configured.
1158    ///
1159    /// The typical flow:
1160    /// 1. [`make_pty`] returns `(master, slave)`;
1161    /// 2. [`pty_window`] applies the initial size to the master and reads back
1162    ///    the actual `winsize`;
1163    /// 3. the caller derives `LINES`/`COLUMNS` from that read-back;
1164    /// 4. this method hands ownership of the pair to Core.
1165    ///
1166    /// Core takes ownership of both descriptors and is responsible for their
1167    /// cleanup on every spawn success/failure path. The builder (and the
1168    /// resulting [`SpawnOptions`]) is move-only from this point — a pty pair
1169    /// is not `Clone`able, so neither is the builder that owns it.
1170    pub fn pty_with(mut self, master: Fd, slave: Fd) -> Self {
1171        self.pty = true;
1172        self.pty_fds = Some((master, slave));
1173        self
1174    }
1175
1176    /// Arm `PR_SET_PDEATHSIG` on the spawned child (opt-in).
1177    ///
1178    /// When set, the child receives `sig` when the **parent thread that
1179    /// created it** exits (see [`SpawnOptions::pdeath_signal`] for the exact
1180    /// semantics and scope). The child arms the signal before any other setup
1181    /// and aborts if `getppid()` no longer matches its expected parent —
1182    /// closing the fork→prctl race that would otherwise leave the signal
1183    /// silently undelivered.
1184    pub fn pdeath_signal(mut self, sig: i32) -> Self {
1185        self.pdeath_signal = Some(sig);
1186        self
1187    }
1188
1189    /// Set the natural-exit policy for a contained session (see
1190    /// [`SessionExitPolicy`]). Defaults to [`SessionExitPolicy::Sweep`].
1191    pub fn session_exit(mut self, policy: SessionExitPolicy) -> Self {
1192        self.session_exit = policy;
1193        self
1194    }
1195
1196    /// Build the spawn options.
1197    pub fn build(self) -> Result<SpawnOptions, CoreError> {
1198        let ctx = ExecContext::new(self.argv, self.env, self.cwd)?;
1199        Ok(SpawnOptions {
1200            ctx,
1201            stdin: self.stdin,
1202            capture_stdout: self.capture_stdout,
1203            capture_stderr: self.capture_stderr,
1204            wait: self.wait,
1205            pgroup: self.pgroup,
1206            session_containment: self.session_containment,
1207            max_output: self.max_output,
1208            timeout_ms: self.timeout_ms,
1209            kill_grace_ms: self.kill_grace_ms,
1210            cancel: self.cancel,
1211            backend: self.backend,
1212            fd_policy: self.fd_policy,
1213            early_exit: self.early_exit,
1214            chunk_sink: self.chunk_sink,
1215            pty: self.pty,
1216            pty_fds: self.pty_fds,
1217            pdeath_signal: self.pdeath_signal,
1218            session_exit: self.session_exit,
1219        })
1220    }
1221}
1222
1223/// The result of a process execution.
1224#[derive(Debug)]
1225pub struct Output {
1226    /// The PID of the finished process.
1227    pub pid: pid_t,
1228    /// Final exit status (None if `wait=false`).
1229    pub status: Option<ExitStatus>,
1230    /// Captured stdout buffer.
1231    pub stdout: Vec<u8>,
1232    /// Captured stderr buffer.
1233    pub stderr: Vec<u8>,
1234    /// Whether the process timed out.
1235    pub timed_out: bool,
1236    /// Whether stdout drain stopped because the early-exit callback matched.
1237    pub stdout_early_exited: bool,
1238    /// Streaming mode: the stdout chunk held while the sink queue was full at
1239    /// completion (empty/none when no sink was attached). The caller must
1240    /// flush it before delivering the terminal frame.
1241    pub stdout_pending: Option<Vec<u8>>,
1242    /// Streaming mode: the stderr chunk held while the sink queue was full at
1243    /// completion.
1244    pub stderr_pending: Option<Vec<u8>>,
1245    /// The session sweep that allowed completion SIGKILLed at least one live
1246    /// session member (a background/contained process that outlived the
1247    /// leader). `true` means the job's own exit did not leave the session
1248    /// empty — the caller (daemon) may want to surface this to the user.
1249    /// Always `false` for non-session spawns and under
1250    /// [`SessionExitPolicy::LetMembersSurvive`].
1251    pub swept_members: bool,
1252}
1253
1254fn validate_backend(opts: &SpawnOptions) -> Result<(), CoreError> {
1255    validate_fd_policy(&opts.fd_policy)?;
1256    if opts.pty {
1257        // Pty mode has no stdin path yet: the child's stdin is the slave, and
1258        // writing to it would go through the master, which the drain does not
1259        // expose until TX_EXEC_WRITE-style write support lands. A stdin buffer
1260        // with pty mode would silently target a pipe that does not exist.
1261        if opts.stdin.is_some() {
1262            return Err(CoreError::sys(
1263                libc::EINVAL,
1264                "pty stdin unsupported (write support pending)",
1265            ));
1266        }
1267    }
1268    match opts.backend {
1269        SpawnBackend::PosixSpawn => {
1270            if opts.pty {
1271                return Err(CoreError::sys(
1272                    libc::EINVAL,
1273                    "posix_spawn pty unsupported (no child setup step)",
1274                ));
1275            }
1276            if opts.ctx.cwd.is_some() {
1277                return Err(CoreError::sys(libc::EINVAL, "posix_spawn cwd unsupported"));
1278            }
1279            if opts.pgroup.isolated {
1280                return Err(CoreError::sys(
1281                    libc::EINVAL,
1282                    "posix_spawn setsid unsupported",
1283                ));
1284            }
1285            if opts.session_containment {
1286                return Err(CoreError::sys(
1287                    libc::EINVAL,
1288                    "posix_spawn session containment unsupported",
1289                ));
1290            }
1291            if opts.pdeath_signal.is_some() {
1292                return Err(CoreError::sys(
1293                    libc::EINVAL,
1294                    "posix_spawn pdeath_signal unsupported (no child setup step)",
1295                ));
1296            }
1297            if opts.fd_policy != SpawnFdPolicy::CloexecOnly {
1298                return Err(CoreError::sys(
1299                    libc::EINVAL,
1300                    "posix_spawn fd policy unsupported",
1301                ));
1302            }
1303            Ok(())
1304        }
1305        SpawnBackend::Fork
1306        | SpawnBackend::Vfork
1307        | SpawnBackend::Clone3
1308        | SpawnBackend::Clone3Pidfd => {
1309            // After `setsid` the child is a session leader in a brand-new
1310            // session; `setpgid(0, leader)` for a leader outside that session
1311            // always fails with EPERM. A zero leader means "own pid" (the
1312            // child's own group after setsid), which is valid. Applies to
1313            // every exec-style backend: they all run the same child setup.
1314            if opts.pgroup.isolated && opts.pgroup.leader.is_some_and(|l| l != 0) {
1315                return Err(CoreError::sys(
1316                    libc::EINVAL,
1317                    "exec isolated + custom setpgid leader unsupported",
1318                ));
1319            }
1320            // Session containment pins the child to the group/session the
1321            // daemon placed it in; without isolation there is no such
1322            // boundary to pin to.
1323            if opts.session_containment && !opts.pgroup.isolated {
1324                return Err(CoreError::sys(
1325                    libc::EINVAL,
1326                    "session containment requires an isolated process group",
1327                ));
1328            }
1329            // A controlling terminal requires the child to be a session
1330            // leader first (TIOCSCTTY fails with EPERM otherwise).
1331            if opts.pty && !opts.pgroup.isolated {
1332                return Err(CoreError::sys(
1333                    libc::EINVAL,
1334                    "pty requires an isolated process group",
1335                ));
1336            }
1337            Ok(())
1338        }
1339    }
1340}
1341
1342fn validate_fd_policy(policy: &SpawnFdPolicy) -> Result<(), CoreError> {
1343    if let SpawnFdPolicy::Allowlist(fds) = policy {
1344        let mut seen = Vec::with_capacity(fds.len());
1345        for &fd in fds {
1346            if fd < 0 {
1347                return Err(CoreError::sys(libc::EINVAL, "spawn fd allowlist invalid"));
1348            }
1349            let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
1350            if flags < 0 {
1351                return Err(CoreError::sys(errno(), "spawn fd allowlist fcntl(F_GETFD)"));
1352            }
1353            if seen.contains(&fd) {
1354                return Err(CoreError::sys(libc::EINVAL, "spawn fd allowlist duplicate"));
1355            }
1356            seen.push(fd);
1357        }
1358    }
1359    Ok(())
1360}
1361
1362/// Specialized drain state for process spawning.
1363pub type SpawnDrain = DrainState<fn(&[u8]) -> bool>;
1364
1365/// A process that is currently running and being monitored.
1366///
1367/// ### Fork Safety
1368/// This handle contains both a PID and owned file descriptors for process I/O.
1369/// Upon `fork`, the descriptors are inherited. Standard `O_CLOEXEC` behavior
1370/// applies after `exec`.
1371pub struct RunningProcess {
1372    /// Handle to the process.
1373    pub process: Process,
1374    drain: SpawnDrain,
1375}
1376
1377/// Full process lifecycle driven by a caller-owned reactor.
1378///
1379/// `ManagedProcess` preserves the blocking [`spawn`] semantics while allowing
1380/// an application reactor to stay responsive: Core owns timeout/cancellation
1381/// escalation, process-group signaling, pipe draining, overflow reporting, and
1382/// `waitpid` reaping; the caller only routes readiness events and polls on
1383/// [`Self::next_deadline`].
1384pub struct ManagedProcess {
1385    running: Option<RunningProcess>,
1386    pid: pid_t,
1387    timeout_at: Option<Instant>,
1388    kill_grace: Duration,
1389    cancel: CancelPolicy,
1390    pgroup: ProcessGroup,
1391    cancel_at: Option<Instant>,
1392    kill_state: KillState,
1393    status: Option<ExitStatus>,
1394    timed_out: bool,
1395    kill_sent_at: Option<Instant>,
1396    deadline_passed_at: Option<Instant>,
1397    /// When the natural-path session sweep first started (see
1398    /// [`SessionExitPolicy::Sweep`]). Bounds the sweep so a D-state member
1399    /// cannot keep `/proc` re-enumeration alive forever (the F6 give-up).
1400    sweep_started_at: Option<Instant>,
1401    /// Set when a natural-exit session sweep found and SIGKILLed at least one
1402    /// live session member. Surfaced on [`Output::swept_members`] (F14) so
1403    /// the caller can distinguish "clean exit" from "exit that killed a
1404    /// contained background member".
1405    swept_members: bool,
1406    /// Natural-exit policy for the contained session (see [`SessionExitPolicy`]).
1407    session_exit: SessionExitPolicy,
1408    /// True when the spawn is a pty session. Routes the kill paths through
1409    /// the session-total machinery ([`signal_session_pgids`]) instead of the
1410    /// single-group kill, and requires the pty master's EOF (drain
1411    /// `io_done`) as the authoritative completion condition rather than
1412    /// leader-reaped — the leader may be reaped while background pgrps still
1413    /// hold the slave (pty job-control dilemma doc §5b).
1414    pty: bool,
1415}
1416
1417impl RunningProcess {
1418    /// Register active stdio pipe descriptors with a reactor.
1419    ///
1420    /// Call this once after [`spawn_start`] when the process was started with
1421    /// captured output or stdin data. The assigned tokens are kept internally
1422    /// and later matched by [`Self::handle_reactor_event`].
1423    pub fn register_with_reactor(&mut self, reactor: &mut Reactor) -> Result<(), CoreError> {
1424        self.drain.register_with_reactor(reactor)
1425    }
1426
1427    /// Apply one reactor readiness event to this process' stdio drain state.
1428    ///
1429    /// Events for unrelated tokens are ignored. Callers remain responsible for
1430    /// waiting on [`Self::process`] and driving the reactor until [`Self::io_done`]
1431    /// returns true.
1432    pub fn handle_reactor_event(
1433        &mut self,
1434        reactor: &mut Reactor,
1435        event: &crate::fd::Event,
1436    ) -> Result<(), CoreError> {
1437        if self.drain.stdout_matches(event.token) {
1438            if event.readable || event.hangup {
1439                self.drain.handle_stdout_ready(reactor)?;
1440            } else if event.error {
1441                self.drain.drop_stdout(reactor)?;
1442            }
1443        } else if self.drain.stderr_matches(event.token) {
1444            if event.readable || event.hangup {
1445                self.drain.handle_stderr_ready(reactor)?;
1446            } else if event.error {
1447                self.drain.drop_stderr(reactor)?;
1448            }
1449        } else if self.drain.stdin_matches(event.token) {
1450            if event.writable {
1451                self.drain.handle_stdin_writable(reactor)?;
1452            } else if event.error || event.hangup {
1453                self.drain.drop_stdin(reactor)?;
1454            }
1455        }
1456        Ok(())
1457    }
1458
1459    /// Return whether all managed stdio pipes have been drained or closed.
1460    pub fn io_done(&self) -> bool {
1461        self.drain.is_done()
1462    }
1463
1464    /// Return whether the stdout stream is paused on a full sink queue.
1465    pub fn stdout_paused(&self) -> bool {
1466        self.drain.stdout_paused()
1467    }
1468
1469    /// Return whether the stderr stream is paused on a full sink queue.
1470    pub fn stderr_paused(&self) -> bool {
1471        self.drain.stderr_paused()
1472    }
1473
1474    /// Re-deliver the held stdout chunk (if any) and re-register the fd when
1475    /// the sink has room again. Returns `true` when the stream is resumed.
1476    pub fn resume_stdout(&mut self, reactor: &mut Reactor) -> Result<bool, CoreError> {
1477        self.drain.resume_stdout(reactor)
1478    }
1479
1480    /// Re-deliver the held stderr chunk (if any) and re-register the fd when
1481    /// the sink has room again. Returns `true` when the stream is resumed.
1482    pub fn resume_stderr(&mut self, reactor: &mut Reactor) -> Result<bool, CoreError> {
1483        self.drain.resume_stderr(reactor)
1484    }
1485
1486    /// Consume the running process handle and return captured stdout/stderr buffers.
1487    pub fn into_output_parts(self) -> (Vec<u8>, Vec<u8>) {
1488        self.drain.into_parts()
1489    }
1490
1491    /// Apply a new terminal window size to a pty-spawned child.
1492    ///
1493    /// Only valid for a [`SpawnOptionsBuilder::pty`] spawn with an active
1494    /// stdout stream; callers typically follow this with a `SIGWINCH` to the
1495    /// child (or its foreground group) so the program can re-read the size.
1496    ///
1497    /// ### Errors
1498    /// - `EINVAL`: The spawn was not a pty spawn, or `rows`/`cols` is zero.
1499    /// - `ENOTTY`: The pty master is unexpectedly not a terminal.
1500    pub fn resize_pty(&self, rows: u16, cols: u16) -> Result<(), CoreError> {
1501        self.drain.resize_pty(rows, cols)
1502    }
1503
1504    /// Write bytes to a pty-spawned child's stdin (the master end).
1505    ///
1506    /// Only valid for a [`SpawnOptionsBuilder::pty`] spawn with an active
1507    /// stdout stream; the write is accepted by the tty line discipline and
1508    /// delivered to the child as its stdin.
1509    ///
1510    /// ### Errors
1511    /// - `EINVAL`: The spawn was not a pty spawn, or the stream is already
1512    ///   closed.
1513    /// - `EIO`: All slave holders have closed (master-side write failure).
1514    /// - `ETIMEDOUT`: The child did not drain its input within the bound.
1515    pub fn write_input(&self, bytes: &[u8]) -> Result<usize, CoreError> {
1516        self.drain.write_input(bytes)
1517    }
1518
1519    /// Write bytes to a pty-spawned child's stdin without blocking.
1520    ///
1521    /// Returns `Ok(Some(n))` for the bytes written (may be a partial write
1522    /// when the tty input buffer fills), or `Ok(None)` on `EAGAIN` (buffer
1523    /// full). The caller owns the input queue: register `POLLOUT` interest on
1524    /// the master on `EAGAIN` and retry on writability.
1525    ///
1526    /// ### Errors
1527    /// - `EINVAL`: The spawn was not a pty spawn, or the stream is already
1528    ///   closed.
1529    /// - `EIO`: All slave holders have closed (master-side write failure).
1530    pub fn write_input_nonblock(&self, bytes: &[u8]) -> Result<Option<usize>, CoreError> {
1531        self.drain.write_input_nonblock(bytes)
1532    }
1533
1534    /// Arm or disarm the pty master's WRITABLE interest (the input route).
1535    ///
1536    /// Direction-preserving: the readable (output) interest is never touched.
1537    /// The caller arms this when its input queue fills and flushes on each
1538    /// writable event, disarming when the queue drains. See
1539    /// [`DrainState::set_pty_writable`].
1540    ///
1541    /// ### Errors
1542    /// - `EINVAL`: The spawn was not a pty spawn, or the stream is already
1543    ///   closed.
1544    pub fn set_pty_writable(
1545        &mut self,
1546        reactor: &mut Reactor,
1547        writable: bool,
1548    ) -> Result<(), CoreError> {
1549        self.drain.set_pty_writable(reactor, writable)
1550    }
1551
1552    /// The pty master's input-route reactor token, when this is a pty spawn.
1553    /// `None` for pipe mode (no input route). See
1554    /// [`DrainState::pty_input_token`].
1555    pub fn pty_input_token(&self) -> Option<Token> {
1556        self.drain.pty_input_token()
1557    }
1558}
1559
1560impl ManagedProcess {
1561    /// Return the child PID.
1562    ///
1563    /// The PID is captured at spawn time, so this remains available after the
1564    /// process has completed (unlike the running handle, which is consumed).
1565    pub fn pid(&self) -> pid_t {
1566        self.pid
1567    }
1568
1569    /// Register active child I/O descriptors with the caller's reactor.
1570    pub fn register_with_reactor(&mut self, reactor: &mut Reactor) -> Result<(), CoreError> {
1571        self.running
1572            .as_mut()
1573            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1574            .register_with_reactor(reactor)
1575    }
1576
1577    /// Route one reactor event to the child's I/O drain state.
1578    pub fn handle_reactor_event(
1579        &mut self,
1580        reactor: &mut Reactor,
1581        event: &crate::fd::Event,
1582    ) -> Result<(), CoreError> {
1583        self.running
1584            .as_mut()
1585            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1586            .handle_reactor_event(reactor, event)
1587    }
1588
1589    /// Return whether the stdout stream is paused on a full sink queue.
1590    pub fn stdout_paused(&self) -> bool {
1591        self.running
1592            .as_ref()
1593            .is_some_and(|running| running.stdout_paused())
1594    }
1595
1596    /// Return whether the stderr stream is paused on a full sink queue.
1597    pub fn stderr_paused(&self) -> bool {
1598        self.running
1599            .as_ref()
1600            .is_some_and(|running| running.stderr_paused())
1601    }
1602
1603    /// Re-deliver the held stdout chunk (if any) and re-register the fd when
1604    /// the sink has room again. Returns `true` when the stream is resumed.
1605    pub fn resume_stdout(&mut self, reactor: &mut Reactor) -> Result<bool, CoreError> {
1606        self.running
1607            .as_mut()
1608            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1609            .resume_stdout(reactor)
1610    }
1611
1612    /// Re-deliver the held stderr chunk (if any) and re-register the fd when
1613    /// the sink has room again. Returns `true` when the stream is resumed.
1614    pub fn resume_stderr(&mut self, reactor: &mut Reactor) -> Result<bool, CoreError> {
1615        self.running
1616            .as_mut()
1617            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1618            .resume_stderr(reactor)
1619    }
1620
1621    /// Request cancellation using the daemon-owned policy from
1622    /// [`SpawnOptionsBuilder::cancel`]. Repeated requests are idempotent.
1623    pub fn request_cancel(&mut self) {
1624        self.cancel_at.get_or_insert_with(Instant::now);
1625    }
1626
1627    /// Earliest time at which [`Self::poll_completion`] should run again.
1628    ///
1629    /// A bounded reap tick is returned while the child is live, and exact
1630    /// timeout / TERM-to-KILL deadlines take precedence. `None` means the
1631    /// completion was already consumed.
1632    pub fn next_deadline(&self) -> Option<Instant> {
1633        self.running.as_ref()?;
1634        let now = Instant::now();
1635        let mut next = now + Duration::from_millis(100);
1636        if !self.timed_out
1637            && let Some(timeout_at) = self.timeout_at
1638            && timeout_at < next
1639        {
1640            next = timeout_at;
1641        }
1642        if self.kill_state == KillState::TermSent
1643            && let Some(cancel_at) = self.cancel_at
1644        {
1645            let kill_at = cancel_at + self.kill_grace;
1646            if kill_at < next {
1647                next = kill_at;
1648            }
1649        }
1650        // D-state bound: wake the caller once the post-SIGKILL reap window has
1651        // elapsed so `poll_completion` can give up on an unreapable child.
1652        if let Some(sent_at) = self.kill_sent_at {
1653            let bail_at = sent_at + D_STATE_REAP_BOUND;
1654            if bail_at < next {
1655                next = bail_at;
1656            }
1657        }
1658        // F6 sweep bound: the natural-path session sweep that started at
1659        // `sweep_started_at` must also wake the caller past the D-state bound,
1660        // or a D-state member would keep /proc re-enumeration alive forever.
1661        if let Some(started) = self.sweep_started_at {
1662            let bail_at = started + D_STATE_REAP_BOUND;
1663            if bail_at < next {
1664                next = bail_at;
1665            }
1666        }
1667        Some(next)
1668    }
1669
1670    /// Advance timeout/cancellation, reap state, and completion.
1671    ///
1672    /// Returns `Ok(None)` while work remains, the normal [`Output`] once the
1673    /// child is reaped and its pipes are drained, or `EOVERFLOW` when the
1674    /// configured combined output limit was exceeded on the fully-drained
1675    /// path. A forced-close (timeout/cancel with a wedged pipe) returns the
1676    /// partial output and the `timed_out` flag instead, matching blocking
1677    /// [`spawn`].
1678    pub fn poll_completion(&mut self, reactor: &mut Reactor) -> Result<Option<Output>, CoreError> {
1679        let now = Instant::now();
1680        if !self.timed_out
1681            && let Some(timeout_at) = self.timeout_at
1682            && now >= timeout_at
1683        {
1684            self.timed_out = true;
1685            self.cancel_at.get_or_insert(timeout_at);
1686            if self.cancel == CancelPolicy::None {
1687                // `CancelPolicy::None` never signals, so the D-state bound
1688                // below never fires; record when the deadline passed so the
1689                // give-up bound mirrors blocking `spawn` (finding 14).
1690                self.deadline_passed_at = Some(self.deadline_passed_at.unwrap_or(now));
1691            }
1692        }
1693
1694        self.advance_cancel(now)?;
1695
1696        let running = self
1697            .running
1698            .as_ref()
1699            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
1700        if self.status.is_none() {
1701            self.status = running.process.wait_step()?;
1702        }
1703
1704        let io_done = running.io_done();
1705        let paused = running.stdout_paused() || running.stderr_paused();
1706        // A paused stream (full sink queue, fd removed from the reactor) can
1707        // never make progress on its own: once the child is reaped, finish with
1708        // the partial output and the held pending chunk instead of waiting for
1709        // a readiness event that will never arrive.
1710        if self.status.is_some() {
1711            let finished = if self.pty {
1712                if self.cancel_at.is_some() {
1713                    // Cancellation: the pty master EOF is the authoritative
1714                    // completion (the leader may be reaped while background
1715                    // pgrps still hold the slave, §5b), bounded by the same
1716                    // D-state give-up as the pipe path. A paused stream must
1717                    // not short-circuit the session kill loop.
1718                    let bounded = io_done
1719                        || self
1720                            .kill_sent_at
1721                            .is_some_and(|t| now.duration_since(t) >= D_STATE_REAP_BOUND)
1722                        || (self.cancel == CancelPolicy::None
1723                            && self.deadline_passed_at.is_some_and(|passed| {
1724                                now.duration_since(passed) >= D_STATE_REAP_BOUND
1725                            }));
1726                    // H2: the D-state bound must not report the job ended while
1727                    // the master is open and session members remain — the bound
1728                    // only proves SIGKILL was sent 500 ms ago, and a member that
1729                    // survived it (D-state, or forked into the final window) would
1730                    // escape. Hold completion until the session is empty; the
1731                    // per-tick sweep keeps the SIGKILLs coming. `CancelPolicy::None`
1732                    // opted out of all signaling and keeps the legacy bound.
1733                    if self.cancel == CancelPolicy::None {
1734                        bounded
1735                    } else {
1736                        bounded && self.session_sweep_if_leader()?
1737                    }
1738                } else {
1739                    // H1/F5: natural completion for a pty session. `Sweep`
1740                    // (default) treats leader-reap as the sweep trigger, not
1741                    // master EOF: a slave-holding background member keeps the
1742                    // master open, so EOF alone would hang completion forever
1743                    // and the sweep (gated behind EOF) would never run
1744                    // (finding A4-3). The sweep runs on every tick and SIGKILLs
1745                    // the slave-holder; EOF fires once it dies, and completion
1746                    // stays gated on an empty session + drain. `LetMembersSurvive`
1747                    // reports completion on leader-reap without signaling the
1748                    // session (nohup-style background jobs keep running) —
1749                    // leader-reap remains the gate, so the hang stays
1750                    // impossible. `CancelPolicy::None` opted out of all
1751                    // signaling and keeps the legacy EOF-based completion.
1752                    if self.cancel == CancelPolicy::None {
1753                        io_done || paused
1754                    } else {
1755                        match self.session_exit {
1756                            SessionExitPolicy::LetMembersSurvive => true,
1757                            SessionExitPolicy::Sweep => {
1758                                let swept = self.session_sweep_if_leader()?;
1759                                if !swept {
1760                                    // The sweep found live members (and
1761                                    // SIGKILLed them) — surface that on the
1762                                    // completion output (F14).
1763                                    self.swept_members = true;
1764                                    if self.sweep_started_at.is_none() {
1765                                        self.sweep_started_at = Some(now);
1766                                    }
1767                                }
1768                                (io_done || paused) && swept
1769                            }
1770                        }
1771                    }
1772                }
1773            } else if self.cancel_at.is_some() {
1774                // H4: the group kill stops once the leader is reaped (its pid
1775                // may be recycled), but contained descendants — TERM-immune,
1776                // stopped, or D-state — would then escape unmanaged. Keep
1777                // sweeping the isolated session until /proc shows no live
1778                // members before reporting completion; `session_sweep` fires
1779                // the SIGKILLs and `advance_cancel` short-circuits on the
1780                // reaped leader (finding H4). `CancelPolicy::None` opted out
1781                // of all signaling and keeps the legacy leader-reap
1782                // completion.
1783                self.cancel == CancelPolicy::None || self.session_sweep_if_leader()?
1784            } else {
1785                // H6/F5: natural pipe completion. A non-session spawn has no
1786                // sweep surface (`session_sweep_if_leader` returns true), so
1787                // the legacy EOF gate holds. An isolated pipe session with
1788                // `Sweep` gets the same leader-reap sweep semantics as a pty
1789                // (an fd-detached descendant would otherwise survive a job
1790                // reported exit-0 — finding H6); `LetMembersSurvive` reports
1791                // on leader-reap without signaling.
1792                if self.cancel == CancelPolicy::None {
1793                    io_done || paused
1794                } else {
1795                    match self.session_exit {
1796                        SessionExitPolicy::LetMembersSurvive => {
1797                            if self.pgroup.isolated {
1798                                true
1799                            } else {
1800                                io_done || paused
1801                            }
1802                        }
1803                        SessionExitPolicy::Sweep => {
1804                            let swept = self.session_sweep_if_leader()?;
1805                            if !swept {
1806                                self.swept_members = true;
1807                                if self.sweep_started_at.is_none() {
1808                                    self.sweep_started_at = Some(now);
1809                                }
1810                            }
1811                            (io_done || paused) && swept
1812                        }
1813                    }
1814                }
1815            };
1816            if finished {
1817                return self.finish(reactor, !io_done).map(Some);
1818            }
1819        }
1820        // D-state / sweep give-up (F6): the SIGKILL for an unreapable leader has
1821        // been pending past the bound, OR the natural-path session sweep has
1822        // been running past the bound without converging (a D-state member
1823        // keeps SIGKILL pending until it wakes). The sweep arm has no
1824        // `status.is_none()` requirement: a reaped leader with a live member
1825        // in D-state must also give up, or /proc re-enumeration runs forever.
1826        let kill_gave_up = self.status.is_none()
1827            && self
1828                .kill_sent_at
1829                .is_some_and(|t| now.duration_since(t) >= D_STATE_REAP_BOUND);
1830        let sweep_gave_up = self
1831            .sweep_started_at
1832            .is_some_and(|t| now.duration_since(t) >= D_STATE_REAP_BOUND);
1833        if kill_gave_up || sweep_gave_up {
1834            // H2: the give-up exists because the *leader* is unreapable or a
1835            // *member* cannot be killed, not because the kill has converged —
1836            // stopping the sweep here would let them leak. Hand the session to
1837            // the detached reaper (safe: the live, unreapable leader still
1838            // pins the sid; a reaped leader makes the starttime-gated
1839            // `orphan_session` a safe no-op — the pending SIGKILL from the
1840            // last sweep tick dies when the member wakes). The reaper keeps
1841            // SIGKILLing until /proc empties.
1842            if self.pty || self.pgroup.isolated {
1843                orphan_session(self.pid);
1844            }
1845            return self.finish(reactor, true).map(Some);
1846        }
1847        // `CancelPolicy::None`: the deadline elapsed but nothing was ever
1848        // signaled, so a wedged child would poll forever. Give up with the
1849        // partial output after the same bound as the D-state path (finding 14).
1850        if self.status.is_none()
1851            && self.cancel == CancelPolicy::None
1852            && self
1853                .deadline_passed_at
1854                .is_some_and(|passed| now.duration_since(passed) >= D_STATE_REAP_BOUND)
1855        {
1856            return self.finish(reactor, true).map(Some);
1857        }
1858        Ok(None)
1859    }
1860
1861    /// Track B (H1/H6): run the session-emptiness sweep iff the child is an
1862    /// isolated session leader. A pty spawn always is (`setsid`, validated);
1863    /// an isolated pipe spawn setsid's too, so `sid == self.pid` and a `/proc`
1864    /// scan by `self.pid` reaches exactly the contained session. A non-isolated
1865    /// pipe spawn shares the caller's session — scanning by `self.pid` would
1866    /// hit unrelated processes, so it stays on the legacy EOF-based completion.
1867    /// Returns `Ok(true)` when there is nothing to sweep (non-session spawn) or
1868    /// the session is empty; `Err` when the scan failed (F7 fail-closed).
1869    fn session_sweep_if_leader(&mut self) -> Result<bool, CoreError> {
1870        if !(self.pty || self.pgroup.isolated) {
1871            return Ok(true);
1872        }
1873        session_sweep(self.pid)
1874    }
1875
1876    fn advance_cancel(&mut self, now: Instant) -> Result<(), CoreError> {
1877        let Some(cancel_at) = self.cancel_at else {
1878            return Ok(());
1879        };
1880        // A reaped child must not be signaled — its pid may be recycled.
1881        // Exception: a pty session, where the leader may be reaped while
1882        // background pgrps still hold the master; those are the session kill
1883        // loop's responsibility. For a non-pty session the reaped leader ends
1884        // the group kill, but contained descendants are swept by the H4
1885        // completion gate in `poll_completion` (kill-totality), so they do
1886        // not escape unmanaged either.
1887        if self.status.is_some() && !self.pty {
1888            return Ok(());
1889        }
1890        let running = self
1891            .running
1892            .as_ref()
1893            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
1894        if self.pty {
1895            return self.advance_pty_cancel(now, cancel_at, running.io_done());
1896        }
1897        let process = &running.process;
1898        let pid = process.pid();
1899        let pgid = effective_pgid(pid, self.pgroup);
1900        let target_is_group = self.pgroup.isolated || self.pgroup.leader.is_some();
1901        match self.kill_state {
1902            KillState::None => match self.cancel {
1903                CancelPolicy::None => {}
1904                CancelPolicy::Graceful => {
1905                    let result = signal_process(process, target_is_group, pgid, libc::SIGTERM);
1906                    self.kill_state = if result.is_ok() {
1907                        KillState::TermSent
1908                    } else {
1909                        KillState::KillSent
1910                    };
1911                    if self.kill_state == KillState::KillSent {
1912                        self.kill_sent_at = Some(now);
1913                    }
1914                }
1915                CancelPolicy::Kill => {
1916                    let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
1917                    self.kill_state = KillState::KillSent;
1918                    self.kill_sent_at = Some(now);
1919                }
1920            },
1921            KillState::TermSent if now >= cancel_at + self.kill_grace => {
1922                let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
1923                self.kill_state = KillState::KillSent;
1924                self.kill_sent_at = Some(now);
1925            }
1926            _ => {}
1927        }
1928        Ok(())
1929    }
1930
1931    /// The pty-session cancellation state machine (§5b of the pty job-control
1932    /// dilemma doc): enumerate the session's PGIDs once, SIGCONT+SIGTERM each,
1933    /// then after the grace period re-enumerate and SIGKILL every group that
1934    /// is still present, repeating the (bounded) re-enumeration while the pty
1935    /// master is still open. The master's EOF — not "all PGIDs disappeared" —
1936    /// is the authoritative completion condition: it is the kernel's own
1937    /// observation that every slave holder has exited. The D-state give-up
1938    /// bound in [`ManagedProcess::poll_completion`] caps the total `/proc`
1939    /// cost of a pathological spawner that keeps creating descendants.
1940    fn advance_pty_cancel(
1941        &mut self,
1942        now: Instant,
1943        cancel_at: Instant,
1944        master_eof: bool,
1945    ) -> Result<(), CoreError> {
1946        // Once the master has EOF'd the session is empty by the kernel's own
1947        // account — there is nothing left to signal.
1948        if master_eof {
1949            return Ok(());
1950        }
1951        // A pty spawn is a session leader (`setsid`): sid == leader pid.
1952        let sid = self.pid;
1953        match self.kill_state {
1954            KillState::None => match self.cancel {
1955                CancelPolicy::None => {}
1956                CancelPolicy::Graceful => {
1957                    signal_session_pgids(sid, libc::SIGTERM, true)?;
1958                    self.kill_state = KillState::TermSent;
1959                }
1960                CancelPolicy::Kill => {
1961                    signal_session_pgids(sid, libc::SIGKILL, false)?;
1962                    self.kill_state = KillState::KillSent;
1963                    self.kill_sent_at = Some(now);
1964                }
1965            },
1966            KillState::TermSent if now >= cancel_at + self.kill_grace => {
1967                signal_session_pgids(sid, libc::SIGKILL, false)?;
1968                self.kill_state = KillState::KillSent;
1969                self.kill_sent_at = Some(now);
1970            }
1971            // Bounded re-enumeration: a group created after the first snapshot
1972            // is still a slave holder and keeps the master open; SIGKILL it.
1973            // `kill_sent_at` drives the give-up bound in `poll_completion`.
1974            KillState::KillSent => {
1975                signal_session_pgids(sid, libc::SIGKILL, false)?;
1976            }
1977            _ => {}
1978        }
1979        Ok(())
1980    }
1981
1982    fn finish(&mut self, reactor: &mut Reactor, force_close: bool) -> Result<Output, CoreError> {
1983        let mut running = self
1984            .running
1985            .take()
1986            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
1987        for slot in running.drain.take_all_slots() {
1988            if slot.token.is_none() {
1989                continue;
1990            }
1991            if force_close {
1992                let _ = reactor.del(&slot.fd);
1993            } else {
1994                reactor.del(&slot.fd)?;
1995            }
1996        }
1997        let pid = running.process.pid();
1998        let stdout_pending = running.drain.take_stdout_pending();
1999        let stderr_pending = running.drain.take_stderr_pending();
2000        let (stdout, stderr, output_limit_exceeded, stdout_early_exited) =
2001            running.drain.into_parts_with_state();
2002        // If the child was never reaped (D-state give-up / forced close with an
2003        // unreapable child), it will eventually exit and become a zombie — hand
2004        // it to the reaper so it does not accumulate in a long-lived daemon
2005        // (finding 15).
2006        if self.status.is_none() {
2007            orphan_child(pid);
2008        }
2009        // Mirror blocking `spawn`: overflow is reported only when the drain
2010        // completed naturally. On the forced-close path (timeout/cancel with a
2011        // wedged pipe) the caller gets the partial output and the timed-out
2012        // flag instead, matching the blocking N4 behavior.
2013        if output_limit_exceeded && !force_close {
2014            return Err(CoreError::sys(libc::EOVERFLOW, "spawn output limit"));
2015        }
2016        Ok(Output {
2017            pid,
2018            status: self.status.take(),
2019            stdout,
2020            stderr,
2021            timed_out: self.timed_out,
2022            stdout_early_exited,
2023            stdout_pending,
2024            stderr_pending,
2025            swept_members: self.swept_members,
2026        })
2027    }
2028
2029    /// Apply a new terminal window size to a pty-spawned child.
2030    ///
2031    /// Only valid for a [`SpawnOptionsBuilder::pty`] spawn with an active
2032    /// stdout stream; callers typically follow this with a `SIGWINCH` to the
2033    /// child (or its foreground group) so the program can re-read the size.
2034    ///
2035    /// ### Errors
2036    /// - `EINVAL`: The spawn was not a pty spawn, the stream is already
2037    ///   closed, or `rows`/`cols` is zero.
2038    /// - `ENOTTY`: The pty master is unexpectedly not a terminal.
2039    pub fn resize_pty(&self, rows: u16, cols: u16) -> Result<(), CoreError> {
2040        self.running
2041            .as_ref()
2042            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
2043            .resize_pty(rows, cols)
2044    }
2045
2046    /// Write bytes to a pty-spawned child's stdin (the master end).
2047    ///
2048    /// Only valid for a [`SpawnOptionsBuilder::pty`] spawn with an active
2049    /// stdout stream. See [`RunningProcess::write_input`].
2050    pub fn write_input(&self, bytes: &[u8]) -> Result<usize, CoreError> {
2051        self.running
2052            .as_ref()
2053            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
2054            .write_input(bytes)
2055    }
2056
2057    /// Write bytes to a pty-spawned child's stdin without blocking.
2058    ///
2059    /// Only valid for a [`SpawnOptionsBuilder::pty`] spawn with an active
2060    /// stdout stream. See [`RunningProcess::write_input_nonblock`].
2061    pub fn write_input_nonblock(&self, bytes: &[u8]) -> Result<Option<usize>, CoreError> {
2062        self.running
2063            .as_ref()
2064            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
2065            .write_input_nonblock(bytes)
2066    }
2067
2068    /// Arm or disarm the pty master's WRITABLE interest (the input route).
2069    ///
2070    /// Only valid for a [`SpawnOptionsBuilder::pty`] spawn with an active
2071    /// stdout stream. See [`RunningProcess::set_pty_writable`].
2072    pub fn set_pty_writable(
2073        &mut self,
2074        reactor: &mut Reactor,
2075        writable: bool,
2076    ) -> Result<(), CoreError> {
2077        self.running
2078            .as_mut()
2079            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
2080            .set_pty_writable(reactor, writable)
2081    }
2082
2083    /// The pty master's input-route reactor token, when this is a pty spawn.
2084    /// `None` for pipe mode (no input route). See
2085    /// [`RunningProcess::pty_input_token`].
2086    pub fn pty_input_token(&self) -> Option<Token> {
2087        self.running
2088            .as_ref()
2089            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))
2090            .ok()
2091            .and_then(|p| p.pty_input_token())
2092    }
2093}
2094
2095impl Drop for ManagedProcess {
2096    fn drop(&mut self) {
2097        let Some(running) = self.running.take() else {
2098            return;
2099        };
2100        let process = &running.process;
2101        let pid = process.pid();
2102        let session_like = self.pty || self.pgroup.isolated;
2103        // If the child was already reaped by `poll_completion`, the pid may
2104        // have been recycled — never signal it blindly. For a NON-session
2105        // spawn this is the end: the pipes are dropped with `running` and
2106        // there is nothing left to clean up. For a SESSION spawn (pty /
2107        // isolated) the leader can be reaped while contained members still
2108        // hold the pty slave — the session kill must still run (F1/F2). The
2109        // fresh sweep is itself the freshness check: it only SIGKILLs groups
2110        // that are live in the session *right now*, so a recycled sid whose
2111        // leader is gone cannot be hit by a stale numeric kill. If the sweep
2112        // does not converge, hand the session to the detached reaper
2113        // (starttime-gated, so it will not sweep a recycled sid either).
2114        if self.status.is_some() {
2115            if !session_like || self.cancel == CancelPolicy::None {
2116                return;
2117            }
2118            // F7 fail-closed: an Err sweep must NOT be treated as "empty" —
2119            // hand the session to the reaper (which keeps retrying) instead
2120            // of silently reporting a clean Drop with live members.
2121            if !session_sweep(pid).unwrap_or(false) {
2122                orphan_session(pid);
2123            }
2124            return;
2125        }
2126        // Respect CancelPolicy::None: "do nothing on cancellation" must not
2127        // kill the child on Drop either — the caller asked that cancellation
2128        // leave the child alone.
2129        if self.cancel != CancelPolicy::None {
2130            if self.pty {
2131                // Session-total kill: the pty session may be fragmented into
2132                // several pgrps; enumerate once and SIGKILL every group.
2133                if signal_session_pgids(self.pid, libc::SIGKILL, false).is_err() {
2134                    // Scan failure: the kill cannot be verified total. Hand the
2135                    // session to the reaper, which keeps retrying each tick
2136                    // (F7 fail-closed — never drop without a confirmed kill).
2137                    orphan_session(self.pid);
2138                }
2139            } else {
2140                let pgid = effective_pgid(pid, self.pgroup);
2141                let target_is_group = self.pgroup.isolated || self.pgroup.leader.is_some();
2142                let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
2143            }
2144        }
2145        // Bound the reap wait: SIGKILL terminates a runnable child
2146        // immediately, but a child stuck in uninterruptible sleep (D-state)
2147        // never dies. Poll with WNOHANG so `Drop` cannot wedge the caller's
2148        // reactor thread forever on a stuck child.
2149        let deadline = Instant::now() + Duration::from_millis(100);
2150        while Instant::now() < deadline {
2151            match process.wait_step() {
2152                Ok(Some(_)) => return,
2153                Ok(None) => std::thread::sleep(Duration::from_millis(5)),
2154                Err(_) => return,
2155            }
2156        }
2157        // Give-up: the child is unreapable right now (D-state) or still
2158        // running under `CancelPolicy::None`. Nobody will `waitpid` it now;
2159        // hand it to the reaper so it does not become a zombie on exit.
2160        orphan_child(pid);
2161    }
2162}
2163
2164fn effective_pgid(pid: pid_t, pgroup: ProcessGroup) -> pid_t {
2165    match pgroup.leader {
2166        Some(0) | None => pid,
2167        Some(leader) => leader,
2168    }
2169}
2170
2171fn signal_process(
2172    process: &Process,
2173    target_is_group: bool,
2174    pgid: pid_t,
2175    signal: i32,
2176) -> Result<(), CoreError> {
2177    if target_is_group {
2178        process.kill_group(pgid, signal)
2179    } else {
2180        process.kill(signal)
2181    }
2182}
2183
2184/// Enumerate the distinct process group ids that share session `sid`, from a
2185/// single `/proc` traversal.
2186///
2187/// Used only at termination time (see the pty job-control dilemma doc §5b): a
2188/// session does thousands of reads/writes/resizes but is cancelled at most
2189/// once, so one scan per cancellation is bounded and unobjectionable — never
2190/// in the streaming path. Natural completion (the H1/H6 emptiness gate) also
2191/// scans: EOF alone is not authoritative, so termination is where the session
2192/// is verified empty (or swept).
2193///
2194/// The test-only enumeration counter ([`pty_session_enumerated`]) proves that
2195/// invariant: a live, streaming session performs zero `/proc` walks.
2196fn session_pgids(sid: pid_t) -> Result<HashSet<pid_t>, CoreError> {
2197    session_pgids_at(std::path::Path::new("/proc"), sid)
2198}
2199
2200/// [`session_pgids`] against an explicit proc root — the testable seam (F7):
2201/// a scan against a root that cannot be read must return `Err`, never a
2202/// fabricated empty set.
2203pub(super) fn session_pgids_at(
2204    proc_root: &std::path::Path,
2205    sid: pid_t,
2206) -> Result<HashSet<pid_t>, CoreError> {
2207    #[cfg(test)]
2208    PTY_SESSION_ENUMERATIONS.lock().unwrap().insert(sid);
2209    let mut pgids = HashSet::new();
2210    // Fail-closed: a session that cannot be scanned must not silently look
2211    // empty (F7). A false "empty" would let the caller report the job
2212    // COMPLETED with contained members still live. The caller decides how to
2213    // react (fail the job, keep the sid registered for the reaper's next
2214    // 250 ms tick); Core never fabricates an empty scan.
2215    let entries = std::fs::read_dir(proc_root).map_err(|e| {
2216        CoreError::sys(
2217            e.raw_os_error().unwrap_or(libc::EIO),
2218            "session_pgids:read_dir",
2219        )
2220    })?;
2221    for entry in entries.flatten() {
2222        let name = entry.file_name();
2223        let Some(name) = name.to_str() else { continue };
2224        let Ok(_pid) = name.parse::<pid_t>() else {
2225            continue;
2226        };
2227        // /proc/[pid]/stat: "pid (comm) state ppid pgrp session tty_nr tpgid …".
2228        // `comm` may contain spaces and ')' — split on the LAST ')'.
2229        let Ok(stat) = std::fs::read_to_string(proc_root.join(name).join("stat")) else {
2230            continue;
2231        };
2232        let Some(rest) = stat.rsplit_once(')') else {
2233            continue;
2234        };
2235        let Some(rest) = rest.1.strip_prefix(' ') else {
2236            continue;
2237        };
2238        let mut fields = rest.split(' ');
2239        let _state = fields.next();
2240        let _ppid = fields.next();
2241        let Some(pgrp) = fields.next().and_then(|f| f.parse::<pid_t>().ok()) else {
2242            continue;
2243        };
2244        let Some(sess) = fields.next().and_then(|f| f.parse::<pid_t>().ok()) else {
2245            continue;
2246        };
2247        if sess == sid && pgrp > 0 {
2248            pgids.insert(pgrp);
2249        }
2250    }
2251    Ok(pgids)
2252}
2253
2254/// Test-only record of which session ids have been enumerated via
2255/// [`session_pgids`]. Compiled out of production builds — the enumeration
2256/// itself is a rare control-plane operation (once per termination), and this
2257/// record exists solely to prove it never enters the terminal data path.
2258///
2259/// Per-session (not a global counter) so tests can run in parallel: each pty
2260/// test asserts its own session was never enumerated during streaming and was
2261/// enumerated at termination, without interference from other tests' sessions.
2262#[cfg(test)]
2263static PTY_SESSION_ENUMERATIONS: std::sync::LazyLock<Mutex<HashSet<pid_t>>> =
2264    std::sync::LazyLock::new(|| Mutex::new(HashSet::new()));
2265
2266/// Clear the test-only enumeration record ([`PTY_SESSION_ENUMERATIONS`]).
2267#[cfg(test)]
2268pub(crate) fn reset_pty_enumeration() {
2269    PTY_SESSION_ENUMERATIONS.lock().unwrap().clear();
2270}
2271
2272/// Whether the given session has been enumerated via [`session_pgids`] since
2273/// the last reset. A live, streaming session must report `false` — only
2274/// cancellation/drop enumerates.
2275#[cfg(test)]
2276pub(crate) fn pty_session_enumerated(sid: pid_t) -> bool {
2277    PTY_SESSION_ENUMERATIONS.lock().unwrap().contains(&sid)
2278}
2279
2280/// Signal every process group in the session identified by `sid`.
2281///
2282/// A pty spawn is a session leader (`setsid` ⇒ sid == leader pid), and job
2283/// control can fragment the session into many pgrps (`setpgid` is allowed
2284/// under the pty containment variant). `kill(-sid)` would only reach the
2285/// leader's own group, and the pty master's EOF is an observation, not a
2286/// delivery mechanism (§5b E1/E2) — so the kill mechanism is: enumerate the
2287/// session's PGIDs at termination and signal each one (§5b E3). A group that
2288/// has already exited yields `ESRCH` and is ignored, matching the existing
2289/// `signal_process` behavior.
2290///
2291/// F7 fail-closed: a session whose scan fails returns `Err` instead of
2292/// silently signaling nobody — a "clean" termination that never killed the
2293/// contained members must not look successful. The caller decides whether to
2294/// retry, fail the job, or hand the session to the reaper.
2295fn signal_session_pgids(sid: pid_t, sig: i32, cont_before: bool) -> Result<(), CoreError> {
2296    for pgid in session_pgids(sid)? {
2297        if cont_before {
2298            // A stopped group must be continued before it can take the signal;
2299            // otherwise SIGTERM is queued and the process stays immune.
2300            unsafe {
2301                libc::kill(-pgid, libc::SIGCONT);
2302            }
2303        }
2304        unsafe {
2305            libc::kill(-pgid, sig);
2306        }
2307    }
2308    Ok(())
2309}
2310
2311/// Track B (H1/H6): report whether the session `sid` is empty, and when it is
2312/// not, SIGKILL every contained live group.
2313///
2314/// Gates natural completion: master/pipe EOF proves the stdio fds closed, not
2315/// that the session is empty — a slave-/fd-detached background member would
2316/// otherwise outlive a job reported COMPLETED (pty job-control dilemma doc
2317/// §5b, findings H1/H6). `setsid` is denied under the containment filter, so
2318/// every member stays in this session and the sweep is total; the caller
2319/// re-checks until this returns `true`.
2320///
2321/// Only LIVE members count toward emptiness: a zombie has already died and is
2322/// merely awaiting reap by its parent/init — SIGKILL on it is a no-op and it
2323/// cannot outlive the job, so gating on it would delay completion by init's
2324/// reap timing. A member stuck in D-state keeps the signal pending until it
2325/// leaves D-state (H2 seam; tracked in REDTEAM-NATIVE-MIGRATION-REVIEW.md).
2326fn session_sweep(sid: pid_t) -> Result<bool, CoreError> {
2327    session_sweep_at(std::path::Path::new("/proc"), sid)
2328}
2329
2330/// [`session_sweep`] against an explicit proc root — the testable seam (F7):
2331/// a scan against a root that cannot be read must return `Err`, never a
2332/// fabricated "empty" (which would report the job COMPLETED with live
2333/// members).
2334pub(super) fn session_sweep_at(proc_root: &std::path::Path, sid: pid_t) -> Result<bool, CoreError> {
2335    #[cfg(test)]
2336    PTY_SESSION_ENUMERATIONS.lock().unwrap().insert(sid);
2337    let mut live_pgrps = HashSet::new();
2338    // Fail-closed (F7): a session that cannot be scanned must not silently
2339    // look empty. `Ok(true)` means "empty" and is the ONLY thing that lets
2340    // the caller report COMPLETED; returning `true` on a failed scan would
2341    // report the job done with contained members still live. The caller
2342    // decides how to react; Core never fabricates an empty scan.
2343    let entries = std::fs::read_dir(proc_root).map_err(|e| {
2344        CoreError::sys(
2345            e.raw_os_error().unwrap_or(libc::EIO),
2346            "session_sweep:read_dir",
2347        )
2348    })?;
2349    for entry in entries.flatten() {
2350        let name = entry.file_name();
2351        let Some(name) = name.to_str() else { continue };
2352        let Ok(_pid) = name.parse::<pid_t>() else {
2353            continue;
2354        };
2355        // /proc/[pid]/stat: "pid (comm) state ppid pgrp session tty_nr tpgid …".
2356        // `comm` may contain spaces and ')' — split on the LAST ')'.
2357        let Ok(stat) = std::fs::read_to_string(proc_root.join(name).join("stat")) else {
2358            continue;
2359        };
2360        let Some(rest) = stat.rsplit_once(')') else {
2361            continue;
2362        };
2363        let Some(rest) = rest.1.strip_prefix(' ') else {
2364            continue;
2365        };
2366        let mut fields = rest.split(' ');
2367        let state = fields.next();
2368        let _ppid = fields.next();
2369        let Some(pgrp) = fields.next().and_then(|f| f.parse::<pid_t>().ok()) else {
2370            continue;
2371        };
2372        let Some(sess) = fields.next().and_then(|f| f.parse::<pid_t>().ok()) else {
2373            continue;
2374        };
2375        if sess == sid && pgrp > 0 && state != Some("Z") {
2376            live_pgrps.insert(pgrp);
2377        }
2378    }
2379    if live_pgrps.is_empty() {
2380        return Ok(true);
2381    }
2382    for pgid in live_pgrps {
2383        // A stopped group must be continued before it can take the signal;
2384        // otherwise the queued SIGKILL stays pending and the group survives.
2385        unsafe {
2386            libc::kill(-pgid, libc::SIGCONT);
2387        }
2388        unsafe {
2389            libc::kill(-pgid, libc::SIGKILL);
2390        }
2391    }
2392    Ok(false)
2393}
2394
2395/// Start spawning a process and return a monitor handle.
2396///
2397/// This initializes the pipes and starts the process, but does not block. Use
2398/// [`RunningProcess::register_with_reactor`],
2399/// [`RunningProcess::handle_reactor_event`], [`RunningProcess::io_done`], and
2400/// [`RunningProcess::into_output_parts`] to drive captured stdio without
2401/// exposing internal drain state.
2402///
2403/// ### Errors
2404/// - `EACCES`: Permission denied for the executable.
2405/// - `EINVAL`: Invalid spawn options (e.g. background capture without wait).
2406/// - `EMFILE`: Process limit on open file descriptors hit.
2407/// - `ENOENT`: The executable was not found.
2408/// - `ENOMEM`: Insufficient memory to spawn the process.
2409pub fn spawn_start(opts: SpawnOptions) -> Result<RunningProcess, CoreError> {
2410    if !opts.wait && (opts.stdin.is_some() || opts.capture_stdout || opts.capture_stderr) {
2411        return Err(CoreError::sys(
2412            libc::EINVAL,
2413            "background I/O capture not supported (wait must be true)",
2414        ));
2415    }
2416
2417    validate_backend(&opts)?;
2418
2419    let (process, drain) = match opts.backend {
2420        SpawnBackend::PosixSpawn => spawn_posix_internal(opts)?,
2421        SpawnBackend::Fork => spawn_fork_internal(opts)?,
2422        SpawnBackend::Vfork => spawn_vfork_internal(opts)?,
2423        SpawnBackend::Clone3 => spawn_clone3_internal(opts, false)?,
2424        SpawnBackend::Clone3Pidfd => spawn_clone3_internal(opts, true)?,
2425    };
2426
2427    Ok(RunningProcess { process, drain })
2428}
2429
2430/// Start a process whose complete lifecycle is driven by a caller-owned
2431/// reactor.
2432pub fn spawn_managed(opts: SpawnOptions) -> Result<ManagedProcess, CoreError> {
2433    if !opts.wait {
2434        return Err(CoreError::sys(
2435            libc::EINVAL,
2436            "managed process requires wait=true",
2437        ));
2438    }
2439    let timeout_at = opts
2440        .timeout_ms
2441        .map(|ms| Instant::now() + Duration::from_millis(ms as u64));
2442    let kill_grace = Duration::from_millis(opts.kill_grace_ms as u64);
2443    let cancel = opts.cancel;
2444    let pgroup = opts.pgroup;
2445    let pty = opts.pty;
2446    let session_exit = opts.session_exit;
2447    let running = spawn_start(opts)?;
2448    let pid = running.process.pid();
2449    Ok(ManagedProcess {
2450        running: Some(running),
2451        pid,
2452        timeout_at,
2453        kill_grace,
2454        cancel,
2455        pgroup,
2456        cancel_at: None,
2457        kill_state: KillState::None,
2458        status: None,
2459        timed_out: false,
2460        kill_sent_at: None,
2461        deadline_passed_at: None,
2462        sweep_started_at: None,
2463        swept_members: false,
2464        session_exit,
2465        pty,
2466    })
2467}
2468
2469/// Spawn a process and block until completion or timeout.
2470///
2471/// This is the primary high-level interface for process execution. It handles
2472/// the full lifecycle, including I/O multiplexing and signal management.
2473///
2474/// ### Errors
2475/// Returns the same errors as [`spawn_start`], plus any I/O or reactor errors
2476/// encountered during the wait loop.
2477pub fn spawn(opts: SpawnOptions) -> Result<Output, CoreError> {
2478    let wait = opts.wait;
2479    let timeout_ms = opts.timeout_ms;
2480    let kill_grace_ms = opts.kill_grace_ms;
2481    let cancel = opts.cancel;
2482    let pgroup = opts.pgroup;
2483    let pty = opts.pty;
2484    let session_exit = opts.session_exit;
2485
2486    let mut reactor = Reactor::new()?;
2487    let running = spawn_start(opts)?;
2488
2489    let pid = running.process.pid();
2490    let mut drain = running.drain;
2491
2492    if let Err(e) = drain.register_with_reactor(&mut reactor) {
2493        // The child is live but stdio registration failed; `running` is
2494        // dropped here so nobody will `waitpid` it. Hand it to the reaper.
2495        orphan_child(pid);
2496        return Err(e);
2497    }
2498
2499    if !wait {
2500        let (stdout, stderr) = drain.into_parts();
2501        // The caller will never `wait` on this pid — hand it to the reaper so
2502        // it does not become a zombie when it exits (finding 15).
2503        orphan_child(pid);
2504        return Ok(Output {
2505            pid,
2506            status: None,
2507            stdout,
2508            stderr,
2509            timed_out: false,
2510            stdout_early_exited: false,
2511            stdout_pending: None,
2512            stderr_pending: None,
2513            swept_members: false,
2514        });
2515    }
2516
2517    wait_loop(
2518        running.process,
2519        drain,
2520        reactor,
2521        timeout_ms,
2522        kill_grace_ms,
2523        cancel,
2524        pgroup,
2525        pty,
2526        session_exit,
2527    )
2528}
2529
2530#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2531enum KillState {
2532    None,
2533    TermSent,
2534    KillSent,
2535}
2536
2537#[allow(clippy::too_many_arguments)] // internal completion state machine; grouped params obscure the flow
2538fn wait_loop(
2539    process: Process,
2540    mut drain: crate::io::DrainState<fn(&[u8]) -> bool>,
2541    mut reactor: Reactor,
2542    timeout_ms: Option<u32>,
2543    kill_grace_ms: u32,
2544    cancel: CancelPolicy,
2545    pgroup: ProcessGroup,
2546    pty: bool,
2547    session_exit: SessionExitPolicy,
2548) -> Result<Output, CoreError> {
2549    let pid = process.pid();
2550    // M8: the child's effective pgid is the configured leader when one is set
2551    // (Setpgid is applied after Setsid in the child), else its own pid. A
2552    // timeout must signal `-pgid`; `kill(-pid)` would target a different
2553    // group for a custom leader and the child would never die.
2554    let pgid = effective_pgid(pid, pgroup);
2555    let mut status_raw = process.wait_step()?;
2556    let mut state = KillState::None;
2557    let mut timed_out = false;
2558    // D-state bound: recorded once SIGKILL has been sent. If the child still
2559    // refuses to die (or be reaped) after `D_STATE_REAP_BOUND`, give up and
2560    // return the partial output instead of spinning on a stuck child.
2561    let mut kill_sent_at: Option<Instant> = None;
2562    // When the natural-path session sweep first started (F6 give-up bound for
2563    // a D-state member under `SessionExitPolicy::Sweep`).
2564    let mut sweep_started_at: Option<Instant> = None;
2565    // Deadline give-up bound for `CancelPolicy::None`: no signal is ever sent,
2566    // so `kill_sent_at` stays unset and the D-state bound never fires. A wedged
2567    // child (pipe held open by a descendant, child unreaped) would otherwise
2568    // poll at 100 ms forever. Once the deadline has passed we give up after the
2569    // same bound, returning the partial output with `timed_out` set.
2570    let mut deadline_passed_at: Option<Instant> = None;
2571    // F14: set when a natural-exit session sweep found and SIGKILLed at least
2572    // one live session member; surfaced on `Output::swept_members`.
2573    let mut swept_members = false;
2574
2575    let start_time = std::time::Instant::now();
2576    let deadline = timeout_ms.map(|t| std::time::Duration::from_millis(t as u64));
2577
2578    loop {
2579        let mut poll_timeout = -1;
2580
2581        if let Some(dl) = deadline {
2582            let elapsed = start_time.elapsed();
2583            if elapsed >= dl {
2584                timed_out = true;
2585                deadline_passed_at = Some(deadline_passed_at.unwrap_or_else(Instant::now));
2586                let elapsed_over = (elapsed - dl).as_millis();
2587
2588                let target_is_group = pgroup.isolated || pgroup.leader.is_some();
2589
2590                // Only signal while the child is unreaped. Once waitpid has
2591                // reaped it the pid may already be recycled by the OS — killing
2592                // it would hit an unrelated process. Exception: a pty session,
2593                // where the leader may be reaped while background pgrps still
2594                // hold the master; those are the session kill loop's
2595                // responsibility (§5b). The wedged-pipe path below returns the
2596                // partial output without sending any signal.
2597                if !(status_raw.is_some() && !pty) {
2598                    match state {
2599                        KillState::None => {
2600                            if pty {
2601                                match cancel {
2602                                    CancelPolicy::Graceful => {
2603                                        signal_session_pgids(pid, libc::SIGTERM, true)?;
2604                                        state = KillState::TermSent;
2605                                    }
2606                                    CancelPolicy::Kill => {
2607                                        signal_session_pgids(pid, libc::SIGKILL, false)?;
2608                                        state = KillState::KillSent;
2609                                        kill_sent_at = Some(Instant::now());
2610                                    }
2611                                    CancelPolicy::None => {}
2612                                }
2613                            } else if cancel == CancelPolicy::Graceful {
2614                                let r = if target_is_group {
2615                                    process.kill_group(pgid, libc::SIGTERM)
2616                                } else {
2617                                    process.kill(libc::SIGTERM)
2618                                };
2619                                if r.is_err() {
2620                                    state = KillState::KillSent; // Process already gone
2621                                    kill_sent_at = Some(Instant::now());
2622                                } else {
2623                                    state = KillState::TermSent;
2624                                }
2625                            } else if cancel == CancelPolicy::Kill {
2626                                let _ = if target_is_group {
2627                                    process.kill_group(pgid, libc::SIGKILL)
2628                                } else {
2629                                    process.kill(libc::SIGKILL)
2630                                };
2631                                state = KillState::KillSent;
2632                                kill_sent_at = Some(Instant::now());
2633                            } else {
2634                                // CancelPolicy::None just times out without killing
2635                            }
2636                        }
2637                        KillState::TermSent if elapsed_over > kill_grace_ms as u128 => {
2638                            if pty {
2639                                signal_session_pgids(pid, libc::SIGKILL, false)?;
2640                            } else {
2641                                let _ = if target_is_group {
2642                                    process.kill_group(pgid, libc::SIGKILL)
2643                                } else {
2644                                    process.kill(libc::SIGKILL)
2645                                };
2646                            }
2647                            state = KillState::KillSent;
2648                            kill_sent_at = Some(Instant::now());
2649                        }
2650                        // Bounded re-enumeration for a pty session: a group
2651                        // created after the first snapshot is still a slave
2652                        // holder and keeps the master open; SIGKILL it. The
2653                        // D-state give-up bound below caps the /proc cost.
2654                        KillState::KillSent if pty && !drain.is_done() => {
2655                            signal_session_pgids(pid, libc::SIGKILL, false)?;
2656                        }
2657                        _ => {}
2658                    }
2659                }
2660                poll_timeout = 100; // Poll frequently while waiting for kill to take effect
2661            } else {
2662                let remaining = dl - elapsed;
2663                poll_timeout = remaining.as_millis().min(i32::MAX as u128) as i32;
2664            }
2665        }
2666
2667        if status_raw.is_none()
2668            && let Some(s) = process.wait_step()?
2669        {
2670            status_raw = Some(s);
2671        }
2672
2673        // F5: natural completion for a contained session under `Sweep`
2674        // triggers on leader-reap, not master EOF. A slave-holding background
2675        // member keeps the master open, so EOF alone would hang completion
2676        // forever and the sweep (gated behind `drain.is_done`) would never run
2677        // (finding A4-3). Sweep every tick once the leader is reaped; the
2678        // SIGKILL releases the slave so EOF can fire, and the completion path
2679        // below stays gated on an empty session + drain.
2680        if status_raw.is_some()
2681            && (pty || pgroup.isolated)
2682            && cancel != CancelPolicy::None
2683            && session_exit == SessionExitPolicy::Sweep
2684            && !drain.is_done()
2685        {
2686            // F7 fail-closed: a failed scan must not be treated as "empty" —
2687            // propagate the Err so the job fails rather than reporting COMPLETED
2688            // with live members.
2689            if !session_sweep(pid)? {
2690                swept_members = true;
2691                if sweep_started_at.is_none() {
2692                    sweep_started_at = Some(Instant::now());
2693                }
2694            }
2695        }
2696
2697        // F5: `LetMembersSurvive` reports natural completion on leader-reap
2698        // without signaling the session (nohup-style background jobs keep
2699        // running). The master may still be open — force-close the drain and
2700        // return the partial output with the reaped status.
2701        if status_raw.is_some()
2702            && (pty || pgroup.isolated)
2703            && cancel != CancelPolicy::None
2704            && session_exit == SessionExitPolicy::LetMembersSurvive
2705            && !timed_out
2706        {
2707            for slot in drain.take_all_slots() {
2708                if slot.token.is_some() {
2709                    let _ = reactor.del(&slot.fd);
2710                }
2711            }
2712            let stdout_pending = drain.take_stdout_pending();
2713            let stderr_pending = drain.take_stderr_pending();
2714            let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
2715                drain.into_parts_with_state();
2716            return Ok(Output {
2717                pid,
2718                status: status_raw.take(),
2719                stdout,
2720                stderr,
2721                timed_out,
2722                stdout_early_exited,
2723                stdout_pending,
2724                stderr_pending,
2725                swept_members,
2726            });
2727        }
2728
2729        if drain.is_done() {
2730            let s = if status_raw.is_some() {
2731                status_raw.take()
2732            } else if deadline.is_none() {
2733                // C1: all pipes drained but the child is still alive, and no
2734                // deadline is set → block until it exits (intended semantics).
2735                Some(process.wait_blocking()?)
2736            } else {
2737                // C1: pipes drained with a deadline set → never block here; fall
2738                // through to the bounded `reactor.wait` below so the deadline
2739                // logic at the top of the loop kills and reaps. A later
2740                // `wait_step` reaps the child and we return from this branch.
2741                None
2742            };
2743
2744            if let Some(s) = s {
2745                // H6/F5: EOF + leader reap must not be reported while contained
2746                // session members survive under `Sweep` — a detached descendant
2747                // would outlive a job reported exit-0 (finding H6). Sweep the
2748                // isolated session until /proc shows no live members before
2749                // completing. `LetMembersSurvive` completes on leader-reap
2750                // without signaling (handled by the earlier branch; this is the
2751                // EOF-first path where the leader may still be alive).
2752                // `CancelPolicy::None` opted out of all signaling and keeps the
2753                // legacy EOF-based completion.
2754                let sweep_needed = (pty || pgroup.isolated)
2755                    && cancel != CancelPolicy::None
2756                    && session_exit == SessionExitPolicy::Sweep
2757                    && !session_sweep(pid)?;
2758                if sweep_needed {
2759                    swept_members = true;
2760                    status_raw = Some(s);
2761                    // The pipes are already EOF'd, so there are no further
2762                    // readiness events; bound the reactor wait so the loop
2763                    // re-scans the session at the existing 10 ms cadence
2764                    // without a raw thread sleep (a D-state member keeps the
2765                    // SIGKILL pending until it wakes — H2 seam).
2766                    if sweep_started_at.is_none() {
2767                        sweep_started_at = Some(Instant::now());
2768                    }
2769                    poll_timeout = 10;
2770                } else {
2771                    for slot in drain.take_all_slots() {
2772                        if slot.token.is_some() {
2773                            reactor.del(&slot.fd)?;
2774                        }
2775                    }
2776                    let stdout_pending = drain.take_stdout_pending();
2777                    let stderr_pending = drain.take_stderr_pending();
2778                    let (stdout, stderr, output_limit_exceeded, stdout_early_exited) =
2779                        drain.into_parts_with_state();
2780                    if output_limit_exceeded {
2781                        return Err(CoreError::sys(libc::EOVERFLOW, "spawn output limit"));
2782                    }
2783                    return Ok(Output {
2784                        pid,
2785                        status: Some(s),
2786                        stdout,
2787                        stderr,
2788                        timed_out,
2789                        stdout_early_exited,
2790                        stdout_pending,
2791                        stderr_pending,
2792                        swept_members,
2793                    });
2794                }
2795            }
2796        }
2797
2798        // Streaming mode: a paused stream (full sink queue) cannot progress
2799        // even after the child is reaped — the fd is not registered, so no
2800        // readiness event will ever arrive. Return the partial output and the
2801        // held pending chunk for the caller to flush (the blocking-path mirror
2802        // of `poll_completion`'s paused-finish branch).
2803        if status_raw.is_some() && (drain.stdout_paused() || drain.stderr_paused()) {
2804            // H1/H6/F5: a paused stream + reaped leader must also wait for the
2805            // session to empty before reporting completion under `Sweep` (the
2806            // wait_loop mirror of `poll_completion`'s `io_done || paused`
2807            // gate); `LetMembersSurvive` never sweeps, so it completes here.
2808            if (pty || pgroup.isolated)
2809                && cancel != CancelPolicy::None
2810                && session_exit == SessionExitPolicy::Sweep
2811                && !session_sweep(pid)?
2812            {
2813                swept_members = true;
2814                if sweep_started_at.is_none() {
2815                    sweep_started_at = Some(Instant::now());
2816                }
2817                // Fall through to the bounded reactor wait below: the paused
2818                // stream produces no readiness events, and the backpressure
2819                // block bounds the poll at 10 ms, re-scanning the session on
2820                // the existing cadence without a raw thread sleep.
2821                poll_timeout = 10;
2822            } else {
2823                for slot in drain.take_all_slots() {
2824                    if slot.token.is_some() {
2825                        let _ = reactor.del(&slot.fd);
2826                    }
2827                }
2828                let stdout_pending = drain.take_stdout_pending();
2829                let stderr_pending = drain.take_stderr_pending();
2830                let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
2831                    drain.into_parts_with_state();
2832                return Ok(Output {
2833                    pid,
2834                    status: status_raw,
2835                    stdout,
2836                    stderr,
2837                    timed_out,
2838                    stdout_early_exited,
2839                    stdout_pending,
2840                    stderr_pending,
2841                    swept_members,
2842                });
2843            }
2844        }
2845
2846        // N4: the deadline has elapsed and the child is reaped, but a wedged
2847        // pipe (a descendant inheriting the write end) keeps the drain from
2848        // closing. The absolute deadline is authoritative — return the partial
2849        // output instead of spinning forever. For a pty session the master is
2850        // the drain, and "wedged" means a background pgrp still holds the
2851        // slave: the session kill loop must get its bounded chance first, so
2852        // only finish on master EOF or after the D-state give-up bound.
2853        if timed_out && status_raw.is_some() {
2854            // F7 fail-closed: a failed sweep scan must fail the job (Err)
2855            // rather than let a false "empty" return the timed-out result.
2856            let pty_sweep = if pty && cancel != CancelPolicy::None {
2857                Some(session_sweep(pid)?)
2858            } else {
2859                None
2860            };
2861            let isolated_sweep = if pgroup.isolated && cancel != CancelPolicy::None && !pty {
2862                Some(session_sweep(pid)?)
2863            } else {
2864                None
2865            };
2866            let can_finish = if pty {
2867                if cancel == CancelPolicy::None {
2868                    drain.is_done()
2869                        || kill_sent_at.is_some_and(|t| t.elapsed() >= D_STATE_REAP_BOUND)
2870                } else {
2871                    // H2: the D-state bound must not return the timed-out
2872                    // result while the master is open and session members
2873                    // remain — the bound only proves SIGKILL was sent 500 ms
2874                    // ago. Hold until the sweep empties the session (a
2875                    // D-state member keeps SIGKILL pending until it wakes).
2876                    (drain.is_done()
2877                        || kill_sent_at.is_some_and(|t| t.elapsed() >= D_STATE_REAP_BOUND))
2878                        && pty_sweep.unwrap_or(false)
2879                }
2880            } else if pgroup.isolated && cancel != CancelPolicy::None {
2881                // H4: the group kill stopped when the leader was reaped, but
2882                // contained descendants (e.g. the member holding the pipe
2883                // write end) survive — sweep the session until /proc shows no
2884                // live members before returning the timed-out result
2885                // (kill-totality, finding H4). A D-state member keeps the
2886                // SIGKILL pending until it wakes (H2 seam).
2887                isolated_sweep.unwrap_or(false)
2888            } else {
2889                true
2890            };
2891            if can_finish {
2892                for slot in drain.take_all_slots() {
2893                    if slot.token.is_some() {
2894                        let _ = reactor.del(&slot.fd);
2895                    }
2896                }
2897                let stdout_pending = drain.take_stdout_pending();
2898                let stderr_pending = drain.take_stderr_pending();
2899                let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
2900                    drain.into_parts_with_state();
2901                return Ok(Output {
2902                    pid,
2903                    status: status_raw,
2904                    stdout,
2905                    stderr,
2906                    timed_out: true,
2907                    stdout_early_exited,
2908                    stdout_pending,
2909                    stderr_pending,
2910                    swept_members,
2911                });
2912            }
2913        }
2914
2915        // D-state / sweep give-up (F6): SIGKILL has been sent but the child is
2916        // still unreaped after the bound, OR the natural-path session sweep has
2917        // been running past the bound without converging (a D-state member
2918        // keeps SIGKILL pending until it wakes). A child stuck in
2919        // uninterruptible sleep keeps the signal pending until it leaves
2920        // D-state, so no further wait can succeed — return the partial output
2921        // rather than polling forever. The pid is not signaled again (it may
2922        // be recycled once it finally exits).
2923        let kill_gave_up = kill_sent_at
2924            .is_some_and(|sent_at| sent_at.elapsed() >= D_STATE_REAP_BOUND)
2925            && status_raw.is_none();
2926        let sweep_gave_up = sweep_started_at.is_some_and(|t| t.elapsed() >= D_STATE_REAP_BOUND);
2927        if kill_gave_up || sweep_gave_up {
2928            for slot in drain.take_all_slots() {
2929                if slot.token.is_some() {
2930                    let _ = reactor.del(&slot.fd);
2931                }
2932            }
2933            // H2: the give-up is about the unreapable *leader* or an
2934            // unkillable *member*; others may still be alive, and stopping the
2935            // sweep here would leak them. Hand the session to the detached
2936            // reaper (safe: the live leader pins the sid; a reaped leader
2937            // makes the starttime-gated `orphan_session` a safe no-op), which
2938            // keeps SIGKILLing until /proc empties (finding H2). The pending
2939            // SIGKILL dies when the member wakes.
2940            if pty || pgroup.isolated {
2941                orphan_session(pid);
2942            }
2943            // The child is unreapable right now but will eventually leave
2944            // D-state and exit; nobody will wait on it after this give-up, so
2945            // hand it to the reaper (finding 15). A reaped leader is already
2946            // gone — skip the re-registration.
2947            if status_raw.is_none() {
2948                orphan_child(pid);
2949            }
2950            let stdout_pending = drain.take_stdout_pending();
2951            let stderr_pending = drain.take_stderr_pending();
2952            let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
2953                drain.into_parts_with_state();
2954            return Ok(Output {
2955                pid,
2956                status: status_raw,
2957                stdout,
2958                stderr,
2959                timed_out,
2960                stdout_early_exited,
2961                stdout_pending,
2962                stderr_pending,
2963                swept_members,
2964            });
2965        }
2966
2967        // `CancelPolicy::None`: the deadline elapsed but nothing was ever
2968        // signaled, so the child may stay wedged (pipe held by a descendant,
2969        // child unreaped) indefinitely. Give up with the partial output after
2970        // the same bound as the D-state path — otherwise this polls at 100 ms
2971        // forever (finding 14).
2972        if cancel == CancelPolicy::None
2973            && timed_out
2974            && status_raw.is_none()
2975            && deadline_passed_at.is_some_and(|passed| passed.elapsed() >= D_STATE_REAP_BOUND)
2976        {
2977            for slot in drain.take_all_slots() {
2978                if slot.token.is_some() {
2979                    let _ = reactor.del(&slot.fd);
2980                }
2981            }
2982            // The child was never signaled and may still be running; nobody
2983            // will wait on it now — hand it to the reaper (finding 15).
2984            orphan_child(pid);
2985            let stdout_pending = drain.take_stdout_pending();
2986            let stderr_pending = drain.take_stderr_pending();
2987            let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
2988                drain.into_parts_with_state();
2989            return Ok(Output {
2990                pid,
2991                status: None,
2992                stdout,
2993                stderr,
2994                timed_out: true,
2995                stdout_early_exited,
2996                stdout_pending,
2997                stderr_pending,
2998                swept_members,
2999            });
3000        }
3001
3002        // Streaming backpressure: while a stream is paused its fd is not
3003        // registered (no readiness events). Keep trying to resume so a
3004        // concurrent queue consumer's drained capacity re-registers the fd,
3005        // and bound the poll so the loop cannot block forever on a paused
3006        // stream.
3007        if drain.stdout_paused() || drain.stderr_paused() {
3008            if drain.stdout_paused() {
3009                let _ = drain.resume_stdout(&mut reactor);
3010            }
3011            if drain.stderr_paused() {
3012                let _ = drain.resume_stderr(&mut reactor);
3013            }
3014            if !(0..=10).contains(&poll_timeout) {
3015                poll_timeout = 10;
3016            }
3017        }
3018
3019        let timeout = poll_timeout;
3020
3021        let mut events = Vec::new();
3022        let nevents = reactor.wait(&mut events, 64, timeout)?;
3023
3024        for ev in events.iter().take(nevents) {
3025            if drain.stdout_matches(ev.token) {
3026                if ev.readable || ev.hangup {
3027                    drain.handle_stdout_ready(&mut reactor)?;
3028                } else if ev.error {
3029                    drain.drop_stdout(&mut reactor)?;
3030                }
3031            } else if drain.stderr_matches(ev.token) {
3032                if ev.readable || ev.hangup {
3033                    drain.handle_stderr_ready(&mut reactor)?;
3034                } else if ev.error {
3035                    drain.drop_stderr(&mut reactor)?;
3036                }
3037            } else if drain.stdin_matches(ev.token) {
3038                if ev.writable {
3039                    drain.handle_stdin_writable(&mut reactor)?;
3040                } else if ev.error || ev.hangup {
3041                    drain.drop_stdin(&mut reactor)?;
3042                }
3043            }
3044        }
3045    }
3046}