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;
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) {
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}
1246
1247fn validate_backend(opts: &SpawnOptions) -> Result<(), CoreError> {
1248    validate_fd_policy(&opts.fd_policy)?;
1249    if opts.pty {
1250        // Pty mode has no stdin path yet: the child's stdin is the slave, and
1251        // writing to it would go through the master, which the drain does not
1252        // expose until TX_EXEC_WRITE-style write support lands. A stdin buffer
1253        // with pty mode would silently target a pipe that does not exist.
1254        if opts.stdin.is_some() {
1255            return Err(CoreError::sys(
1256                libc::EINVAL,
1257                "pty stdin unsupported (write support pending)",
1258            ));
1259        }
1260    }
1261    match opts.backend {
1262        SpawnBackend::PosixSpawn => {
1263            if opts.pty {
1264                return Err(CoreError::sys(
1265                    libc::EINVAL,
1266                    "posix_spawn pty unsupported (no child setup step)",
1267                ));
1268            }
1269            if opts.ctx.cwd.is_some() {
1270                return Err(CoreError::sys(libc::EINVAL, "posix_spawn cwd unsupported"));
1271            }
1272            if opts.pgroup.isolated {
1273                return Err(CoreError::sys(
1274                    libc::EINVAL,
1275                    "posix_spawn setsid unsupported",
1276                ));
1277            }
1278            if opts.session_containment {
1279                return Err(CoreError::sys(
1280                    libc::EINVAL,
1281                    "posix_spawn session containment unsupported",
1282                ));
1283            }
1284            if opts.pdeath_signal.is_some() {
1285                return Err(CoreError::sys(
1286                    libc::EINVAL,
1287                    "posix_spawn pdeath_signal unsupported (no child setup step)",
1288                ));
1289            }
1290            if opts.fd_policy != SpawnFdPolicy::CloexecOnly {
1291                return Err(CoreError::sys(
1292                    libc::EINVAL,
1293                    "posix_spawn fd policy unsupported",
1294                ));
1295            }
1296            Ok(())
1297        }
1298        SpawnBackend::Fork
1299        | SpawnBackend::Vfork
1300        | SpawnBackend::Clone3
1301        | SpawnBackend::Clone3Pidfd => {
1302            // After `setsid` the child is a session leader in a brand-new
1303            // session; `setpgid(0, leader)` for a leader outside that session
1304            // always fails with EPERM. A zero leader means "own pid" (the
1305            // child's own group after setsid), which is valid. Applies to
1306            // every exec-style backend: they all run the same child setup.
1307            if opts.pgroup.isolated && opts.pgroup.leader.is_some_and(|l| l != 0) {
1308                return Err(CoreError::sys(
1309                    libc::EINVAL,
1310                    "exec isolated + custom setpgid leader unsupported",
1311                ));
1312            }
1313            // Session containment pins the child to the group/session the
1314            // daemon placed it in; without isolation there is no such
1315            // boundary to pin to.
1316            if opts.session_containment && !opts.pgroup.isolated {
1317                return Err(CoreError::sys(
1318                    libc::EINVAL,
1319                    "session containment requires an isolated process group",
1320                ));
1321            }
1322            // A controlling terminal requires the child to be a session
1323            // leader first (TIOCSCTTY fails with EPERM otherwise).
1324            if opts.pty && !opts.pgroup.isolated {
1325                return Err(CoreError::sys(
1326                    libc::EINVAL,
1327                    "pty requires an isolated process group",
1328                ));
1329            }
1330            Ok(())
1331        }
1332    }
1333}
1334
1335fn validate_fd_policy(policy: &SpawnFdPolicy) -> Result<(), CoreError> {
1336    if let SpawnFdPolicy::Allowlist(fds) = policy {
1337        let mut seen = Vec::with_capacity(fds.len());
1338        for &fd in fds {
1339            if fd < 0 {
1340                return Err(CoreError::sys(libc::EINVAL, "spawn fd allowlist invalid"));
1341            }
1342            let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
1343            if flags < 0 {
1344                return Err(CoreError::sys(errno(), "spawn fd allowlist fcntl(F_GETFD)"));
1345            }
1346            if seen.contains(&fd) {
1347                return Err(CoreError::sys(libc::EINVAL, "spawn fd allowlist duplicate"));
1348            }
1349            seen.push(fd);
1350        }
1351    }
1352    Ok(())
1353}
1354
1355/// Specialized drain state for process spawning.
1356pub type SpawnDrain = DrainState<fn(&[u8]) -> bool>;
1357
1358/// A process that is currently running and being monitored.
1359///
1360/// ### Fork Safety
1361/// This handle contains both a PID and owned file descriptors for process I/O.
1362/// Upon `fork`, the descriptors are inherited. Standard `O_CLOEXEC` behavior
1363/// applies after `exec`.
1364pub struct RunningProcess {
1365    /// Handle to the process.
1366    pub process: Process,
1367    drain: SpawnDrain,
1368}
1369
1370/// Full process lifecycle driven by a caller-owned reactor.
1371///
1372/// `ManagedProcess` preserves the blocking [`spawn`] semantics while allowing
1373/// an application reactor to stay responsive: Core owns timeout/cancellation
1374/// escalation, process-group signaling, pipe draining, overflow reporting, and
1375/// `waitpid` reaping; the caller only routes readiness events and polls on
1376/// [`Self::next_deadline`].
1377pub struct ManagedProcess {
1378    running: Option<RunningProcess>,
1379    pid: pid_t,
1380    timeout_at: Option<Instant>,
1381    kill_grace: Duration,
1382    cancel: CancelPolicy,
1383    pgroup: ProcessGroup,
1384    cancel_at: Option<Instant>,
1385    kill_state: KillState,
1386    status: Option<ExitStatus>,
1387    timed_out: bool,
1388    kill_sent_at: Option<Instant>,
1389    deadline_passed_at: Option<Instant>,
1390    /// When the natural-path session sweep first started (see
1391    /// [`SessionExitPolicy::Sweep`]). Bounds the sweep so a D-state member
1392    /// cannot keep `/proc` re-enumeration alive forever (the F6 give-up).
1393    sweep_started_at: Option<Instant>,
1394    /// Natural-exit policy for the contained session (see [`SessionExitPolicy`]).
1395    session_exit: SessionExitPolicy,
1396    /// True when the spawn is a pty session. Routes the kill paths through
1397    /// the session-total machinery ([`signal_session_pgids`]) instead of the
1398    /// single-group kill, and requires the pty master's EOF (drain
1399    /// `io_done`) as the authoritative completion condition rather than
1400    /// leader-reaped — the leader may be reaped while background pgrps still
1401    /// hold the slave (pty job-control dilemma doc §5b).
1402    pty: bool,
1403}
1404
1405impl RunningProcess {
1406    /// Register active stdio pipe descriptors with a reactor.
1407    ///
1408    /// Call this once after [`spawn_start`] when the process was started with
1409    /// captured output or stdin data. The assigned tokens are kept internally
1410    /// and later matched by [`Self::handle_reactor_event`].
1411    pub fn register_with_reactor(&mut self, reactor: &mut Reactor) -> Result<(), CoreError> {
1412        self.drain.register_with_reactor(reactor)
1413    }
1414
1415    /// Apply one reactor readiness event to this process' stdio drain state.
1416    ///
1417    /// Events for unrelated tokens are ignored. Callers remain responsible for
1418    /// waiting on [`Self::process`] and driving the reactor until [`Self::io_done`]
1419    /// returns true.
1420    pub fn handle_reactor_event(
1421        &mut self,
1422        reactor: &mut Reactor,
1423        event: &crate::fd::Event,
1424    ) -> Result<(), CoreError> {
1425        if self.drain.stdout_matches(event.token) {
1426            if event.readable || event.hangup {
1427                self.drain.handle_stdout_ready(reactor)?;
1428            } else if event.error {
1429                self.drain.drop_stdout(reactor)?;
1430            }
1431        } else if self.drain.stderr_matches(event.token) {
1432            if event.readable || event.hangup {
1433                self.drain.handle_stderr_ready(reactor)?;
1434            } else if event.error {
1435                self.drain.drop_stderr(reactor)?;
1436            }
1437        } else if self.drain.stdin_matches(event.token) {
1438            if event.writable {
1439                self.drain.handle_stdin_writable(reactor)?;
1440            } else if event.error || event.hangup {
1441                self.drain.drop_stdin(reactor)?;
1442            }
1443        }
1444        Ok(())
1445    }
1446
1447    /// Return whether all managed stdio pipes have been drained or closed.
1448    pub fn io_done(&self) -> bool {
1449        self.drain.is_done()
1450    }
1451
1452    /// Return whether the stdout stream is paused on a full sink queue.
1453    pub fn stdout_paused(&self) -> bool {
1454        self.drain.stdout_paused()
1455    }
1456
1457    /// Return whether the stderr stream is paused on a full sink queue.
1458    pub fn stderr_paused(&self) -> bool {
1459        self.drain.stderr_paused()
1460    }
1461
1462    /// Re-deliver the held stdout chunk (if any) and re-register the fd when
1463    /// the sink has room again. Returns `true` when the stream is resumed.
1464    pub fn resume_stdout(&mut self, reactor: &mut Reactor) -> Result<bool, CoreError> {
1465        self.drain.resume_stdout(reactor)
1466    }
1467
1468    /// Re-deliver the held stderr chunk (if any) and re-register the fd when
1469    /// the sink has room again. Returns `true` when the stream is resumed.
1470    pub fn resume_stderr(&mut self, reactor: &mut Reactor) -> Result<bool, CoreError> {
1471        self.drain.resume_stderr(reactor)
1472    }
1473
1474    /// Consume the running process handle and return captured stdout/stderr buffers.
1475    pub fn into_output_parts(self) -> (Vec<u8>, Vec<u8>) {
1476        self.drain.into_parts()
1477    }
1478
1479    /// Apply a new terminal window size to a pty-spawned child.
1480    ///
1481    /// Only valid for a [`SpawnOptionsBuilder::pty`] spawn with an active
1482    /// stdout stream; callers typically follow this with a `SIGWINCH` to the
1483    /// child (or its foreground group) so the program can re-read the size.
1484    ///
1485    /// ### Errors
1486    /// - `EINVAL`: The spawn was not a pty spawn, or `rows`/`cols` is zero.
1487    /// - `ENOTTY`: The pty master is unexpectedly not a terminal.
1488    pub fn resize_pty(&self, rows: u16, cols: u16) -> Result<(), CoreError> {
1489        self.drain.resize_pty(rows, cols)
1490    }
1491
1492    /// Write bytes to a pty-spawned child's stdin (the master end).
1493    ///
1494    /// Only valid for a [`SpawnOptionsBuilder::pty`] spawn with an active
1495    /// stdout stream; the write is accepted by the tty line discipline and
1496    /// delivered to the child as its stdin.
1497    ///
1498    /// ### Errors
1499    /// - `EINVAL`: The spawn was not a pty spawn, or the stream is already
1500    ///   closed.
1501    /// - `EIO`: All slave holders have closed (master-side write failure).
1502    /// - `ETIMEDOUT`: The child did not drain its input within the bound.
1503    pub fn write_input(&self, bytes: &[u8]) -> Result<usize, CoreError> {
1504        self.drain.write_input(bytes)
1505    }
1506
1507    /// Write bytes to a pty-spawned child's stdin without blocking.
1508    ///
1509    /// Returns `Ok(Some(n))` for the bytes written (may be a partial write
1510    /// when the tty input buffer fills), or `Ok(None)` on `EAGAIN` (buffer
1511    /// full). The caller owns the input queue: register `POLLOUT` interest on
1512    /// the master on `EAGAIN` and retry on writability.
1513    ///
1514    /// ### Errors
1515    /// - `EINVAL`: The spawn was not a pty spawn, or the stream is already
1516    ///   closed.
1517    /// - `EIO`: All slave holders have closed (master-side write failure).
1518    pub fn write_input_nonblock(&self, bytes: &[u8]) -> Result<Option<usize>, CoreError> {
1519        self.drain.write_input_nonblock(bytes)
1520    }
1521}
1522
1523impl ManagedProcess {
1524    /// Return the child PID.
1525    ///
1526    /// The PID is captured at spawn time, so this remains available after the
1527    /// process has completed (unlike the running handle, which is consumed).
1528    pub fn pid(&self) -> pid_t {
1529        self.pid
1530    }
1531
1532    /// Register active child I/O descriptors with the caller's reactor.
1533    pub fn register_with_reactor(&mut self, reactor: &mut Reactor) -> Result<(), CoreError> {
1534        self.running
1535            .as_mut()
1536            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1537            .register_with_reactor(reactor)
1538    }
1539
1540    /// Route one reactor event to the child's I/O drain state.
1541    pub fn handle_reactor_event(
1542        &mut self,
1543        reactor: &mut Reactor,
1544        event: &crate::fd::Event,
1545    ) -> Result<(), CoreError> {
1546        self.running
1547            .as_mut()
1548            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1549            .handle_reactor_event(reactor, event)
1550    }
1551
1552    /// Return whether the stdout stream is paused on a full sink queue.
1553    pub fn stdout_paused(&self) -> bool {
1554        self.running
1555            .as_ref()
1556            .is_some_and(|running| running.stdout_paused())
1557    }
1558
1559    /// Return whether the stderr stream is paused on a full sink queue.
1560    pub fn stderr_paused(&self) -> bool {
1561        self.running
1562            .as_ref()
1563            .is_some_and(|running| running.stderr_paused())
1564    }
1565
1566    /// Re-deliver the held stdout chunk (if any) and re-register the fd when
1567    /// the sink has room again. Returns `true` when the stream is resumed.
1568    pub fn resume_stdout(&mut self, reactor: &mut Reactor) -> Result<bool, CoreError> {
1569        self.running
1570            .as_mut()
1571            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1572            .resume_stdout(reactor)
1573    }
1574
1575    /// Re-deliver the held stderr chunk (if any) and re-register the fd when
1576    /// the sink has room again. Returns `true` when the stream is resumed.
1577    pub fn resume_stderr(&mut self, reactor: &mut Reactor) -> Result<bool, CoreError> {
1578        self.running
1579            .as_mut()
1580            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1581            .resume_stderr(reactor)
1582    }
1583
1584    /// Request cancellation using the daemon-owned policy from
1585    /// [`SpawnOptionsBuilder::cancel`]. Repeated requests are idempotent.
1586    pub fn request_cancel(&mut self) {
1587        self.cancel_at.get_or_insert_with(Instant::now);
1588    }
1589
1590    /// Earliest time at which [`Self::poll_completion`] should run again.
1591    ///
1592    /// A bounded reap tick is returned while the child is live, and exact
1593    /// timeout / TERM-to-KILL deadlines take precedence. `None` means the
1594    /// completion was already consumed.
1595    pub fn next_deadline(&self) -> Option<Instant> {
1596        self.running.as_ref()?;
1597        let now = Instant::now();
1598        let mut next = now + Duration::from_millis(100);
1599        if !self.timed_out
1600            && let Some(timeout_at) = self.timeout_at
1601            && timeout_at < next
1602        {
1603            next = timeout_at;
1604        }
1605        if self.kill_state == KillState::TermSent
1606            && let Some(cancel_at) = self.cancel_at
1607        {
1608            let kill_at = cancel_at + self.kill_grace;
1609            if kill_at < next {
1610                next = kill_at;
1611            }
1612        }
1613        // D-state bound: wake the caller once the post-SIGKILL reap window has
1614        // elapsed so `poll_completion` can give up on an unreapable child.
1615        if let Some(sent_at) = self.kill_sent_at {
1616            let bail_at = sent_at + D_STATE_REAP_BOUND;
1617            if bail_at < next {
1618                next = bail_at;
1619            }
1620        }
1621        // F6 sweep bound: the natural-path session sweep that started at
1622        // `sweep_started_at` must also wake the caller past the D-state bound,
1623        // or a D-state member would keep /proc re-enumeration alive forever.
1624        if let Some(started) = self.sweep_started_at {
1625            let bail_at = started + D_STATE_REAP_BOUND;
1626            if bail_at < next {
1627                next = bail_at;
1628            }
1629        }
1630        Some(next)
1631    }
1632
1633    /// Advance timeout/cancellation, reap state, and completion.
1634    ///
1635    /// Returns `Ok(None)` while work remains, the normal [`Output`] once the
1636    /// child is reaped and its pipes are drained, or `EOVERFLOW` when the
1637    /// configured combined output limit was exceeded on the fully-drained
1638    /// path. A forced-close (timeout/cancel with a wedged pipe) returns the
1639    /// partial output and the `timed_out` flag instead, matching blocking
1640    /// [`spawn`].
1641    pub fn poll_completion(&mut self, reactor: &mut Reactor) -> Result<Option<Output>, CoreError> {
1642        let now = Instant::now();
1643        if !self.timed_out
1644            && let Some(timeout_at) = self.timeout_at
1645            && now >= timeout_at
1646        {
1647            self.timed_out = true;
1648            self.cancel_at.get_or_insert(timeout_at);
1649            if self.cancel == CancelPolicy::None {
1650                // `CancelPolicy::None` never signals, so the D-state bound
1651                // below never fires; record when the deadline passed so the
1652                // give-up bound mirrors blocking `spawn` (finding 14).
1653                self.deadline_passed_at = Some(self.deadline_passed_at.unwrap_or(now));
1654            }
1655        }
1656
1657        self.advance_cancel(now)?;
1658
1659        let running = self
1660            .running
1661            .as_ref()
1662            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
1663        if self.status.is_none() {
1664            self.status = running.process.wait_step()?;
1665        }
1666
1667        let io_done = running.io_done();
1668        let paused = running.stdout_paused() || running.stderr_paused();
1669        // A paused stream (full sink queue, fd removed from the reactor) can
1670        // never make progress on its own: once the child is reaped, finish with
1671        // the partial output and the held pending chunk instead of waiting for
1672        // a readiness event that will never arrive.
1673        if self.status.is_some() {
1674            let finished = if self.pty {
1675                if self.cancel_at.is_some() {
1676                    // Cancellation: the pty master EOF is the authoritative
1677                    // completion (the leader may be reaped while background
1678                    // pgrps still hold the slave, §5b), bounded by the same
1679                    // D-state give-up as the pipe path. A paused stream must
1680                    // not short-circuit the session kill loop.
1681                    let bounded = io_done
1682                        || self
1683                            .kill_sent_at
1684                            .is_some_and(|t| now.duration_since(t) >= D_STATE_REAP_BOUND)
1685                        || (self.cancel == CancelPolicy::None
1686                            && self.deadline_passed_at.is_some_and(|passed| {
1687                                now.duration_since(passed) >= D_STATE_REAP_BOUND
1688                            }));
1689                    // H2: the D-state bound must not report the job ended while
1690                    // the master is open and session members remain — the bound
1691                    // only proves SIGKILL was sent 500 ms ago, and a member that
1692                    // survived it (D-state, or forked into the final window) would
1693                    // escape. Hold completion until the session is empty; the
1694                    // per-tick sweep keeps the SIGKILLs coming. `CancelPolicy::None`
1695                    // opted out of all signaling and keeps the legacy bound.
1696                    if self.cancel == CancelPolicy::None {
1697                        bounded
1698                    } else {
1699                        bounded && self.session_sweep_if_leader()
1700                    }
1701                } else {
1702                    // H1/F5: natural completion for a pty session. `Sweep`
1703                    // (default) treats leader-reap as the sweep trigger, not
1704                    // master EOF: a slave-holding background member keeps the
1705                    // master open, so EOF alone would hang completion forever
1706                    // and the sweep (gated behind EOF) would never run
1707                    // (finding A4-3). The sweep runs on every tick and SIGKILLs
1708                    // the slave-holder; EOF fires once it dies, and completion
1709                    // stays gated on an empty session + drain. `LetMembersSurvive`
1710                    // reports completion on leader-reap without signaling the
1711                    // session (nohup-style background jobs keep running) —
1712                    // leader-reap remains the gate, so the hang stays
1713                    // impossible. `CancelPolicy::None` opted out of all
1714                    // signaling and keeps the legacy EOF-based completion.
1715                    if self.cancel == CancelPolicy::None {
1716                        io_done || paused
1717                    } else {
1718                        match self.session_exit {
1719                            SessionExitPolicy::LetMembersSurvive => true,
1720                            SessionExitPolicy::Sweep => {
1721                                let swept = self.session_sweep_if_leader();
1722                                if !swept && self.sweep_started_at.is_none() {
1723                                    self.sweep_started_at = Some(now);
1724                                }
1725                                (io_done || paused) && swept
1726                            }
1727                        }
1728                    }
1729                }
1730            } else if self.cancel_at.is_some() {
1731                // H4: the group kill stops once the leader is reaped (its pid
1732                // may be recycled), but contained descendants — TERM-immune,
1733                // stopped, or D-state — would then escape unmanaged. Keep
1734                // sweeping the isolated session until /proc shows no live
1735                // members before reporting completion; `session_sweep` fires
1736                // the SIGKILLs and `advance_cancel` short-circuits on the
1737                // reaped leader (finding H4). `CancelPolicy::None` opted out
1738                // of all signaling and keeps the legacy leader-reap
1739                // completion.
1740                self.cancel == CancelPolicy::None || self.session_sweep_if_leader()
1741            } else {
1742                // H6/F5: natural pipe completion. A non-session spawn has no
1743                // sweep surface (`session_sweep_if_leader` returns true), so
1744                // the legacy EOF gate holds. An isolated pipe session with
1745                // `Sweep` gets the same leader-reap sweep semantics as a pty
1746                // (an fd-detached descendant would otherwise survive a job
1747                // reported exit-0 — finding H6); `LetMembersSurvive` reports
1748                // on leader-reap without signaling.
1749                if self.cancel == CancelPolicy::None {
1750                    io_done || paused
1751                } else {
1752                    match self.session_exit {
1753                        SessionExitPolicy::LetMembersSurvive => {
1754                            if self.pgroup.isolated {
1755                                true
1756                            } else {
1757                                io_done || paused
1758                            }
1759                        }
1760                        SessionExitPolicy::Sweep => {
1761                            let swept = self.session_sweep_if_leader();
1762                            if !swept && self.sweep_started_at.is_none() {
1763                                self.sweep_started_at = Some(now);
1764                            }
1765                            (io_done || paused) && swept
1766                        }
1767                    }
1768                }
1769            };
1770            if finished {
1771                return self.finish(reactor, !io_done).map(Some);
1772            }
1773        }
1774        // D-state / sweep give-up (F6): the SIGKILL for an unreapable leader has
1775        // been pending past the bound, OR the natural-path session sweep has
1776        // been running past the bound without converging (a D-state member
1777        // keeps SIGKILL pending until it wakes). The sweep arm has no
1778        // `status.is_none()` requirement: a reaped leader with a live member
1779        // in D-state must also give up, or /proc re-enumeration runs forever.
1780        let kill_gave_up = self.status.is_none()
1781            && self
1782                .kill_sent_at
1783                .is_some_and(|t| now.duration_since(t) >= D_STATE_REAP_BOUND);
1784        let sweep_gave_up = self
1785            .sweep_started_at
1786            .is_some_and(|t| now.duration_since(t) >= D_STATE_REAP_BOUND);
1787        if kill_gave_up || sweep_gave_up {
1788            // H2: the give-up exists because the *leader* is unreapable or a
1789            // *member* cannot be killed, not because the kill has converged —
1790            // stopping the sweep here would let them leak. Hand the session to
1791            // the detached reaper (safe: the live, unreapable leader still
1792            // pins the sid; a reaped leader makes the starttime-gated
1793            // `orphan_session` a safe no-op — the pending SIGKILL from the
1794            // last sweep tick dies when the member wakes). The reaper keeps
1795            // SIGKILLing until /proc empties.
1796            if self.pty || self.pgroup.isolated {
1797                orphan_session(self.pid);
1798            }
1799            return self.finish(reactor, true).map(Some);
1800        }
1801        // `CancelPolicy::None`: the deadline elapsed but nothing was ever
1802        // signaled, so a wedged child would poll forever. Give up with the
1803        // partial output after the same bound as the D-state path (finding 14).
1804        if self.status.is_none()
1805            && self.cancel == CancelPolicy::None
1806            && self
1807                .deadline_passed_at
1808                .is_some_and(|passed| now.duration_since(passed) >= D_STATE_REAP_BOUND)
1809        {
1810            return self.finish(reactor, true).map(Some);
1811        }
1812        Ok(None)
1813    }
1814
1815    /// Track B (H1/H6): run the session-emptiness sweep iff the child is an
1816    /// isolated session leader. A pty spawn always is (`setsid`, validated);
1817    /// an isolated pipe spawn setsid's too, so `sid == self.pid` and a `/proc`
1818    /// scan by `self.pid` reaches exactly the contained session. A non-isolated
1819    /// pipe spawn shares the caller's session — scanning by `self.pid` would
1820    /// hit unrelated processes, so it stays on the legacy EOF-based completion.
1821    fn session_sweep_if_leader(&mut self) -> bool {
1822        if !(self.pty || self.pgroup.isolated) {
1823            return true;
1824        }
1825        session_sweep(self.pid)
1826    }
1827
1828    fn advance_cancel(&mut self, now: Instant) -> Result<(), CoreError> {
1829        let Some(cancel_at) = self.cancel_at else {
1830            return Ok(());
1831        };
1832        // A reaped child must not be signaled — its pid may be recycled.
1833        // Exception: a pty session, where the leader may be reaped while
1834        // background pgrps still hold the master; those are the session kill
1835        // loop's responsibility. For a non-pty session the reaped leader ends
1836        // the group kill, but contained descendants are swept by the H4
1837        // completion gate in `poll_completion` (kill-totality), so they do
1838        // not escape unmanaged either.
1839        if self.status.is_some() && !self.pty {
1840            return Ok(());
1841        }
1842        let running = self
1843            .running
1844            .as_ref()
1845            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
1846        if self.pty {
1847            return self.advance_pty_cancel(now, cancel_at, running.io_done());
1848        }
1849        let process = &running.process;
1850        let pid = process.pid();
1851        let pgid = effective_pgid(pid, self.pgroup);
1852        let target_is_group = self.pgroup.isolated || self.pgroup.leader.is_some();
1853        match self.kill_state {
1854            KillState::None => match self.cancel {
1855                CancelPolicy::None => {}
1856                CancelPolicy::Graceful => {
1857                    let result = signal_process(process, target_is_group, pgid, libc::SIGTERM);
1858                    self.kill_state = if result.is_ok() {
1859                        KillState::TermSent
1860                    } else {
1861                        KillState::KillSent
1862                    };
1863                    if self.kill_state == KillState::KillSent {
1864                        self.kill_sent_at = Some(now);
1865                    }
1866                }
1867                CancelPolicy::Kill => {
1868                    let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
1869                    self.kill_state = KillState::KillSent;
1870                    self.kill_sent_at = Some(now);
1871                }
1872            },
1873            KillState::TermSent if now >= cancel_at + self.kill_grace => {
1874                let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
1875                self.kill_state = KillState::KillSent;
1876                self.kill_sent_at = Some(now);
1877            }
1878            _ => {}
1879        }
1880        Ok(())
1881    }
1882
1883    /// The pty-session cancellation state machine (§5b of the pty job-control
1884    /// dilemma doc): enumerate the session's PGIDs once, SIGCONT+SIGTERM each,
1885    /// then after the grace period re-enumerate and SIGKILL every group that
1886    /// is still present, repeating the (bounded) re-enumeration while the pty
1887    /// master is still open. The master's EOF — not "all PGIDs disappeared" —
1888    /// is the authoritative completion condition: it is the kernel's own
1889    /// observation that every slave holder has exited. The D-state give-up
1890    /// bound in [`ManagedProcess::poll_completion`] caps the total `/proc`
1891    /// cost of a pathological spawner that keeps creating descendants.
1892    fn advance_pty_cancel(
1893        &mut self,
1894        now: Instant,
1895        cancel_at: Instant,
1896        master_eof: bool,
1897    ) -> Result<(), CoreError> {
1898        // Once the master has EOF'd the session is empty by the kernel's own
1899        // account — there is nothing left to signal.
1900        if master_eof {
1901            return Ok(());
1902        }
1903        // A pty spawn is a session leader (`setsid`): sid == leader pid.
1904        let sid = self.pid;
1905        match self.kill_state {
1906            KillState::None => match self.cancel {
1907                CancelPolicy::None => {}
1908                CancelPolicy::Graceful => {
1909                    signal_session_pgids(sid, libc::SIGTERM, true);
1910                    self.kill_state = KillState::TermSent;
1911                }
1912                CancelPolicy::Kill => {
1913                    signal_session_pgids(sid, libc::SIGKILL, false);
1914                    self.kill_state = KillState::KillSent;
1915                    self.kill_sent_at = Some(now);
1916                }
1917            },
1918            KillState::TermSent if now >= cancel_at + self.kill_grace => {
1919                signal_session_pgids(sid, libc::SIGKILL, false);
1920                self.kill_state = KillState::KillSent;
1921                self.kill_sent_at = Some(now);
1922            }
1923            // Bounded re-enumeration: a group created after the first snapshot
1924            // is still a slave holder and keeps the master open; SIGKILL it.
1925            // `kill_sent_at` drives the give-up bound in `poll_completion`.
1926            KillState::KillSent => {
1927                signal_session_pgids(sid, libc::SIGKILL, false);
1928            }
1929            _ => {}
1930        }
1931        Ok(())
1932    }
1933
1934    fn finish(&mut self, reactor: &mut Reactor, force_close: bool) -> Result<Output, CoreError> {
1935        let mut running = self
1936            .running
1937            .take()
1938            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
1939        for slot in running.drain.take_all_slots() {
1940            if slot.token.is_none() {
1941                continue;
1942            }
1943            if force_close {
1944                let _ = reactor.del(&slot.fd);
1945            } else {
1946                reactor.del(&slot.fd)?;
1947            }
1948        }
1949        let pid = running.process.pid();
1950        let stdout_pending = running.drain.take_stdout_pending();
1951        let stderr_pending = running.drain.take_stderr_pending();
1952        let (stdout, stderr, output_limit_exceeded, stdout_early_exited) =
1953            running.drain.into_parts_with_state();
1954        // If the child was never reaped (D-state give-up / forced close with an
1955        // unreapable child), it will eventually exit and become a zombie — hand
1956        // it to the reaper so it does not accumulate in a long-lived daemon
1957        // (finding 15).
1958        if self.status.is_none() {
1959            orphan_child(pid);
1960        }
1961        // Mirror blocking `spawn`: overflow is reported only when the drain
1962        // completed naturally. On the forced-close path (timeout/cancel with a
1963        // wedged pipe) the caller gets the partial output and the timed-out
1964        // flag instead, matching the blocking N4 behavior.
1965        if output_limit_exceeded && !force_close {
1966            return Err(CoreError::sys(libc::EOVERFLOW, "spawn output limit"));
1967        }
1968        Ok(Output {
1969            pid,
1970            status: self.status.take(),
1971            stdout,
1972            stderr,
1973            timed_out: self.timed_out,
1974            stdout_early_exited,
1975            stdout_pending,
1976            stderr_pending,
1977        })
1978    }
1979
1980    /// Apply a new terminal window size to a pty-spawned child.
1981    ///
1982    /// Only valid for a [`SpawnOptionsBuilder::pty`] spawn with an active
1983    /// stdout stream; callers typically follow this with a `SIGWINCH` to the
1984    /// child (or its foreground group) so the program can re-read the size.
1985    ///
1986    /// ### Errors
1987    /// - `EINVAL`: The spawn was not a pty spawn, the stream is already
1988    ///   closed, or `rows`/`cols` is zero.
1989    /// - `ENOTTY`: The pty master is unexpectedly not a terminal.
1990    pub fn resize_pty(&self, rows: u16, cols: u16) -> Result<(), CoreError> {
1991        self.running
1992            .as_ref()
1993            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1994            .resize_pty(rows, cols)
1995    }
1996
1997    /// Write bytes to a pty-spawned child's stdin (the master end).
1998    ///
1999    /// Only valid for a [`SpawnOptionsBuilder::pty`] spawn with an active
2000    /// stdout stream. See [`RunningProcess::write_input`].
2001    pub fn write_input(&self, bytes: &[u8]) -> Result<usize, CoreError> {
2002        self.running
2003            .as_ref()
2004            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
2005            .write_input(bytes)
2006    }
2007
2008    /// Write bytes to a pty-spawned child's stdin without blocking.
2009    ///
2010    /// Only valid for a [`SpawnOptionsBuilder::pty`] spawn with an active
2011    /// stdout stream. See [`RunningProcess::write_input_nonblock`].
2012    pub fn write_input_nonblock(&self, bytes: &[u8]) -> Result<Option<usize>, CoreError> {
2013        self.running
2014            .as_ref()
2015            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
2016            .write_input_nonblock(bytes)
2017    }
2018}
2019
2020impl Drop for ManagedProcess {
2021    fn drop(&mut self) {
2022        let Some(running) = self.running.take() else {
2023            return;
2024        };
2025        let process = &running.process;
2026        let pid = process.pid();
2027        let session_like = self.pty || self.pgroup.isolated;
2028        // If the child was already reaped by `poll_completion`, the pid may
2029        // have been recycled — never signal it blindly. For a NON-session
2030        // spawn this is the end: the pipes are dropped with `running` and
2031        // there is nothing left to clean up. For a SESSION spawn (pty /
2032        // isolated) the leader can be reaped while contained members still
2033        // hold the pty slave — the session kill must still run (F1/F2). The
2034        // fresh sweep is itself the freshness check: it only SIGKILLs groups
2035        // that are live in the session *right now*, so a recycled sid whose
2036        // leader is gone cannot be hit by a stale numeric kill. If the sweep
2037        // does not converge, hand the session to the detached reaper
2038        // (starttime-gated, so it will not sweep a recycled sid either).
2039        if self.status.is_some() {
2040            if !session_like || self.cancel == CancelPolicy::None {
2041                return;
2042            }
2043            if !session_sweep(pid) {
2044                orphan_session(pid);
2045            }
2046            return;
2047        }
2048        // Respect CancelPolicy::None: "do nothing on cancellation" must not
2049        // kill the child on Drop either — the caller asked that cancellation
2050        // leave the child alone.
2051        if self.cancel != CancelPolicy::None {
2052            if self.pty {
2053                // Session-total kill: the pty session may be fragmented into
2054                // several pgrps; enumerate once and SIGKILL every group.
2055                signal_session_pgids(self.pid, libc::SIGKILL, false);
2056            } else {
2057                let pgid = effective_pgid(pid, self.pgroup);
2058                let target_is_group = self.pgroup.isolated || self.pgroup.leader.is_some();
2059                let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
2060            }
2061        }
2062        // Bound the reap wait: SIGKILL terminates a runnable child
2063        // immediately, but a child stuck in uninterruptible sleep (D-state)
2064        // never dies. Poll with WNOHANG so `Drop` cannot wedge the caller's
2065        // reactor thread forever on a stuck child.
2066        let deadline = Instant::now() + Duration::from_millis(100);
2067        while Instant::now() < deadline {
2068            match process.wait_step() {
2069                Ok(Some(_)) => return,
2070                Ok(None) => std::thread::sleep(Duration::from_millis(5)),
2071                Err(_) => return,
2072            }
2073        }
2074        // Give-up: the child is unreapable right now (D-state) or still
2075        // running under `CancelPolicy::None`. Nobody will `waitpid` it now;
2076        // hand it to the reaper so it does not become a zombie on exit.
2077        orphan_child(pid);
2078    }
2079}
2080
2081fn effective_pgid(pid: pid_t, pgroup: ProcessGroup) -> pid_t {
2082    match pgroup.leader {
2083        Some(0) | None => pid,
2084        Some(leader) => leader,
2085    }
2086}
2087
2088fn signal_process(
2089    process: &Process,
2090    target_is_group: bool,
2091    pgid: pid_t,
2092    signal: i32,
2093) -> Result<(), CoreError> {
2094    if target_is_group {
2095        process.kill_group(pgid, signal)
2096    } else {
2097        process.kill(signal)
2098    }
2099}
2100
2101/// Enumerate the distinct process group ids that share session `sid`, from a
2102/// single `/proc` traversal.
2103///
2104/// Used only at termination time (see the pty job-control dilemma doc §5b): a
2105/// session does thousands of reads/writes/resizes but is cancelled at most
2106/// once, so one scan per cancellation is bounded and unobjectionable — never
2107/// in the streaming path. Natural completion (the H1/H6 emptiness gate) also
2108/// scans: EOF alone is not authoritative, so termination is where the session
2109/// is verified empty (or swept).
2110///
2111/// The test-only enumeration counter ([`pty_session_enumerated`]) proves that
2112/// invariant: a live, streaming session performs zero `/proc` walks.
2113fn session_pgids(sid: pid_t) -> HashSet<pid_t> {
2114    #[cfg(test)]
2115    PTY_SESSION_ENUMERATIONS.lock().unwrap().insert(sid);
2116    let mut pgids = HashSet::new();
2117    let Ok(entries) = std::fs::read_dir("/proc") else {
2118        return pgids;
2119    };
2120    for entry in entries.flatten() {
2121        let name = entry.file_name();
2122        let Some(name) = name.to_str() else { continue };
2123        let Ok(_pid) = name.parse::<pid_t>() else {
2124            continue;
2125        };
2126        // /proc/[pid]/stat: "pid (comm) state ppid pgrp session tty_nr tpgid …".
2127        // `comm` may contain spaces and ')' — split on the LAST ')'.
2128        let Ok(stat) = std::fs::read_to_string(format!("/proc/{name}/stat")) else {
2129            continue;
2130        };
2131        let Some(rest) = stat.rsplit_once(')') else {
2132            continue;
2133        };
2134        let Some(rest) = rest.1.strip_prefix(' ') else {
2135            continue;
2136        };
2137        let mut fields = rest.split(' ');
2138        let _state = fields.next();
2139        let _ppid = fields.next();
2140        let Some(pgrp) = fields.next().and_then(|f| f.parse::<pid_t>().ok()) else {
2141            continue;
2142        };
2143        let Some(sess) = fields.next().and_then(|f| f.parse::<pid_t>().ok()) else {
2144            continue;
2145        };
2146        if sess == sid && pgrp > 0 {
2147            pgids.insert(pgrp);
2148        }
2149    }
2150    pgids
2151}
2152
2153/// Test-only record of which session ids have been enumerated via
2154/// [`session_pgids`]. Compiled out of production builds — the enumeration
2155/// itself is a rare control-plane operation (once per termination), and this
2156/// record exists solely to prove it never enters the terminal data path.
2157///
2158/// Per-session (not a global counter) so tests can run in parallel: each pty
2159/// test asserts its own session was never enumerated during streaming and was
2160/// enumerated at termination, without interference from other tests' sessions.
2161#[cfg(test)]
2162static PTY_SESSION_ENUMERATIONS: std::sync::LazyLock<Mutex<HashSet<pid_t>>> =
2163    std::sync::LazyLock::new(|| Mutex::new(HashSet::new()));
2164
2165/// Clear the test-only enumeration record ([`PTY_SESSION_ENUMERATIONS`]).
2166#[cfg(test)]
2167pub(crate) fn reset_pty_enumeration() {
2168    PTY_SESSION_ENUMERATIONS.lock().unwrap().clear();
2169}
2170
2171/// Whether the given session has been enumerated via [`session_pgids`] since
2172/// the last reset. A live, streaming session must report `false` — only
2173/// cancellation/drop enumerates.
2174#[cfg(test)]
2175pub(crate) fn pty_session_enumerated(sid: pid_t) -> bool {
2176    PTY_SESSION_ENUMERATIONS.lock().unwrap().contains(&sid)
2177}
2178
2179/// Signal every process group in the session identified by `sid`.
2180///
2181/// A pty spawn is a session leader (`setsid` ⇒ sid == leader pid), and job
2182/// control can fragment the session into many pgrps (`setpgid` is allowed
2183/// under the pty containment variant). `kill(-sid)` would only reach the
2184/// leader's own group, and the pty master's EOF is an observation, not a
2185/// delivery mechanism (§5b E1/E2) — so the kill mechanism is: enumerate the
2186/// session's PGIDs at termination and signal each one (§5b E3). Fire and
2187/// forget: a group that has already exited yields `ESRCH` and is ignored,
2188/// matching the existing `signal_process` behavior.
2189fn signal_session_pgids(sid: pid_t, sig: i32, cont_before: bool) {
2190    for pgid in session_pgids(sid) {
2191        if cont_before {
2192            // A stopped group must be continued before it can take the signal;
2193            // otherwise SIGTERM is queued and the process stays immune.
2194            unsafe {
2195                libc::kill(-pgid, libc::SIGCONT);
2196            }
2197        }
2198        unsafe {
2199            libc::kill(-pgid, sig);
2200        }
2201    }
2202}
2203
2204/// Track B (H1/H6): report whether the session `sid` is empty, and when it is
2205/// not, SIGKILL every contained live group.
2206///
2207/// Gates natural completion: master/pipe EOF proves the stdio fds closed, not
2208/// that the session is empty — a slave-/fd-detached background member would
2209/// otherwise outlive a job reported COMPLETED (pty job-control dilemma doc
2210/// §5b, findings H1/H6). `setsid` is denied under the containment filter, so
2211/// every member stays in this session and the sweep is total; the caller
2212/// re-checks until this returns `true`.
2213///
2214/// Only LIVE members count toward emptiness: a zombie has already died and is
2215/// merely awaiting reap by its parent/init — SIGKILL on it is a no-op and it
2216/// cannot outlive the job, so gating on it would delay completion by init's
2217/// reap timing. A member stuck in D-state keeps the signal pending until it
2218/// leaves D-state (H2 seam; tracked in REDTEAM-NATIVE-MIGRATION-REVIEW.md).
2219fn session_sweep(sid: pid_t) -> bool {
2220    #[cfg(test)]
2221    PTY_SESSION_ENUMERATIONS.lock().unwrap().insert(sid);
2222    let mut live_pgrps = HashSet::new();
2223    let Ok(entries) = std::fs::read_dir("/proc") else {
2224        return true;
2225    };
2226    for entry in entries.flatten() {
2227        let name = entry.file_name();
2228        let Some(name) = name.to_str() else { continue };
2229        let Ok(_pid) = name.parse::<pid_t>() else {
2230            continue;
2231        };
2232        // /proc/[pid]/stat: "pid (comm) state ppid pgrp session tty_nr tpgid …".
2233        // `comm` may contain spaces and ')' — split on the LAST ')'.
2234        let Ok(stat) = std::fs::read_to_string(format!("/proc/{name}/stat")) else {
2235            continue;
2236        };
2237        let Some(rest) = stat.rsplit_once(')') else {
2238            continue;
2239        };
2240        let Some(rest) = rest.1.strip_prefix(' ') else {
2241            continue;
2242        };
2243        let mut fields = rest.split(' ');
2244        let state = fields.next();
2245        let _ppid = fields.next();
2246        let Some(pgrp) = fields.next().and_then(|f| f.parse::<pid_t>().ok()) else {
2247            continue;
2248        };
2249        let Some(sess) = fields.next().and_then(|f| f.parse::<pid_t>().ok()) else {
2250            continue;
2251        };
2252        if sess == sid && pgrp > 0 && state != Some("Z") {
2253            live_pgrps.insert(pgrp);
2254        }
2255    }
2256    if live_pgrps.is_empty() {
2257        return true;
2258    }
2259    for pgid in live_pgrps {
2260        // A stopped group must be continued before it can take the signal;
2261        // otherwise the queued SIGKILL stays pending and the group survives.
2262        unsafe {
2263            libc::kill(-pgid, libc::SIGCONT);
2264        }
2265        unsafe {
2266            libc::kill(-pgid, libc::SIGKILL);
2267        }
2268    }
2269    false
2270}
2271
2272/// Start spawning a process and return a monitor handle.
2273///
2274/// This initializes the pipes and starts the process, but does not block. Use
2275/// [`RunningProcess::register_with_reactor`],
2276/// [`RunningProcess::handle_reactor_event`], [`RunningProcess::io_done`], and
2277/// [`RunningProcess::into_output_parts`] to drive captured stdio without
2278/// exposing internal drain state.
2279///
2280/// ### Errors
2281/// - `EACCES`: Permission denied for the executable.
2282/// - `EINVAL`: Invalid spawn options (e.g. background capture without wait).
2283/// - `EMFILE`: Process limit on open file descriptors hit.
2284/// - `ENOENT`: The executable was not found.
2285/// - `ENOMEM`: Insufficient memory to spawn the process.
2286pub fn spawn_start(opts: SpawnOptions) -> Result<RunningProcess, CoreError> {
2287    if !opts.wait && (opts.stdin.is_some() || opts.capture_stdout || opts.capture_stderr) {
2288        return Err(CoreError::sys(
2289            libc::EINVAL,
2290            "background I/O capture not supported (wait must be true)",
2291        ));
2292    }
2293
2294    validate_backend(&opts)?;
2295
2296    let (process, drain) = match opts.backend {
2297        SpawnBackend::PosixSpawn => spawn_posix_internal(opts)?,
2298        SpawnBackend::Fork => spawn_fork_internal(opts)?,
2299        SpawnBackend::Vfork => spawn_vfork_internal(opts)?,
2300        SpawnBackend::Clone3 => spawn_clone3_internal(opts, false)?,
2301        SpawnBackend::Clone3Pidfd => spawn_clone3_internal(opts, true)?,
2302    };
2303
2304    Ok(RunningProcess { process, drain })
2305}
2306
2307/// Start a process whose complete lifecycle is driven by a caller-owned
2308/// reactor.
2309pub fn spawn_managed(opts: SpawnOptions) -> Result<ManagedProcess, CoreError> {
2310    if !opts.wait {
2311        return Err(CoreError::sys(
2312            libc::EINVAL,
2313            "managed process requires wait=true",
2314        ));
2315    }
2316    let timeout_at = opts
2317        .timeout_ms
2318        .map(|ms| Instant::now() + Duration::from_millis(ms as u64));
2319    let kill_grace = Duration::from_millis(opts.kill_grace_ms as u64);
2320    let cancel = opts.cancel;
2321    let pgroup = opts.pgroup;
2322    let pty = opts.pty;
2323    let session_exit = opts.session_exit;
2324    let running = spawn_start(opts)?;
2325    let pid = running.process.pid();
2326    Ok(ManagedProcess {
2327        running: Some(running),
2328        pid,
2329        timeout_at,
2330        kill_grace,
2331        cancel,
2332        pgroup,
2333        cancel_at: None,
2334        kill_state: KillState::None,
2335        status: None,
2336        timed_out: false,
2337        kill_sent_at: None,
2338        deadline_passed_at: None,
2339        sweep_started_at: None,
2340        session_exit,
2341        pty,
2342    })
2343}
2344
2345/// Spawn a process and block until completion or timeout.
2346///
2347/// This is the primary high-level interface for process execution. It handles
2348/// the full lifecycle, including I/O multiplexing and signal management.
2349///
2350/// ### Errors
2351/// Returns the same errors as [`spawn_start`], plus any I/O or reactor errors
2352/// encountered during the wait loop.
2353pub fn spawn(opts: SpawnOptions) -> Result<Output, CoreError> {
2354    let wait = opts.wait;
2355    let timeout_ms = opts.timeout_ms;
2356    let kill_grace_ms = opts.kill_grace_ms;
2357    let cancel = opts.cancel;
2358    let pgroup = opts.pgroup;
2359    let pty = opts.pty;
2360    let session_exit = opts.session_exit;
2361
2362    let mut reactor = Reactor::new()?;
2363    let running = spawn_start(opts)?;
2364
2365    let pid = running.process.pid();
2366    let mut drain = running.drain;
2367
2368    if let Err(e) = drain.register_with_reactor(&mut reactor) {
2369        // The child is live but stdio registration failed; `running` is
2370        // dropped here so nobody will `waitpid` it. Hand it to the reaper.
2371        orphan_child(pid);
2372        return Err(e);
2373    }
2374
2375    if !wait {
2376        let (stdout, stderr) = drain.into_parts();
2377        // The caller will never `wait` on this pid — hand it to the reaper so
2378        // it does not become a zombie when it exits (finding 15).
2379        orphan_child(pid);
2380        return Ok(Output {
2381            pid,
2382            status: None,
2383            stdout,
2384            stderr,
2385            timed_out: false,
2386            stdout_early_exited: false,
2387            stdout_pending: None,
2388            stderr_pending: None,
2389        });
2390    }
2391
2392    wait_loop(
2393        running.process,
2394        drain,
2395        reactor,
2396        timeout_ms,
2397        kill_grace_ms,
2398        cancel,
2399        pgroup,
2400        pty,
2401        session_exit,
2402    )
2403}
2404
2405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2406enum KillState {
2407    None,
2408    TermSent,
2409    KillSent,
2410}
2411
2412#[allow(clippy::too_many_arguments)] // internal completion state machine; grouped params obscure the flow
2413fn wait_loop(
2414    process: Process,
2415    mut drain: crate::io::DrainState<fn(&[u8]) -> bool>,
2416    mut reactor: Reactor,
2417    timeout_ms: Option<u32>,
2418    kill_grace_ms: u32,
2419    cancel: CancelPolicy,
2420    pgroup: ProcessGroup,
2421    pty: bool,
2422    session_exit: SessionExitPolicy,
2423) -> Result<Output, CoreError> {
2424    let pid = process.pid();
2425    // M8: the child's effective pgid is the configured leader when one is set
2426    // (Setpgid is applied after Setsid in the child), else its own pid. A
2427    // timeout must signal `-pgid`; `kill(-pid)` would target a different
2428    // group for a custom leader and the child would never die.
2429    let pgid = effective_pgid(pid, pgroup);
2430    let mut status_raw = process.wait_step()?;
2431    let mut state = KillState::None;
2432    let mut timed_out = false;
2433    // D-state bound: recorded once SIGKILL has been sent. If the child still
2434    // refuses to die (or be reaped) after `D_STATE_REAP_BOUND`, give up and
2435    // return the partial output instead of spinning on a stuck child.
2436    let mut kill_sent_at: Option<Instant> = None;
2437    // When the natural-path session sweep first started (F6 give-up bound for
2438    // a D-state member under `SessionExitPolicy::Sweep`).
2439    let mut sweep_started_at: Option<Instant> = None;
2440    // Deadline give-up bound for `CancelPolicy::None`: no signal is ever sent,
2441    // so `kill_sent_at` stays unset and the D-state bound never fires. A wedged
2442    // child (pipe held open by a descendant, child unreaped) would otherwise
2443    // poll at 100 ms forever. Once the deadline has passed we give up after the
2444    // same bound, returning the partial output with `timed_out` set.
2445    let mut deadline_passed_at: Option<Instant> = None;
2446
2447    let start_time = std::time::Instant::now();
2448    let deadline = timeout_ms.map(|t| std::time::Duration::from_millis(t as u64));
2449
2450    loop {
2451        let mut poll_timeout = -1;
2452
2453        if let Some(dl) = deadline {
2454            let elapsed = start_time.elapsed();
2455            if elapsed >= dl {
2456                timed_out = true;
2457                deadline_passed_at = Some(deadline_passed_at.unwrap_or_else(Instant::now));
2458                let elapsed_over = (elapsed - dl).as_millis();
2459
2460                let target_is_group = pgroup.isolated || pgroup.leader.is_some();
2461
2462                // Only signal while the child is unreaped. Once waitpid has
2463                // reaped it the pid may already be recycled by the OS — killing
2464                // it would hit an unrelated process. Exception: a pty session,
2465                // where the leader may be reaped while background pgrps still
2466                // hold the master; those are the session kill loop's
2467                // responsibility (§5b). The wedged-pipe path below returns the
2468                // partial output without sending any signal.
2469                if !(status_raw.is_some() && !pty) {
2470                    match state {
2471                        KillState::None => {
2472                            if pty {
2473                                match cancel {
2474                                    CancelPolicy::Graceful => {
2475                                        signal_session_pgids(pid, libc::SIGTERM, true);
2476                                        state = KillState::TermSent;
2477                                    }
2478                                    CancelPolicy::Kill => {
2479                                        signal_session_pgids(pid, libc::SIGKILL, false);
2480                                        state = KillState::KillSent;
2481                                        kill_sent_at = Some(Instant::now());
2482                                    }
2483                                    CancelPolicy::None => {}
2484                                }
2485                            } else if cancel == CancelPolicy::Graceful {
2486                                let r = if target_is_group {
2487                                    process.kill_group(pgid, libc::SIGTERM)
2488                                } else {
2489                                    process.kill(libc::SIGTERM)
2490                                };
2491                                if r.is_err() {
2492                                    state = KillState::KillSent; // Process already gone
2493                                    kill_sent_at = Some(Instant::now());
2494                                } else {
2495                                    state = KillState::TermSent;
2496                                }
2497                            } else if cancel == CancelPolicy::Kill {
2498                                let _ = if target_is_group {
2499                                    process.kill_group(pgid, libc::SIGKILL)
2500                                } else {
2501                                    process.kill(libc::SIGKILL)
2502                                };
2503                                state = KillState::KillSent;
2504                                kill_sent_at = Some(Instant::now());
2505                            } else {
2506                                // CancelPolicy::None just times out without killing
2507                            }
2508                        }
2509                        KillState::TermSent if elapsed_over > kill_grace_ms as u128 => {
2510                            if pty {
2511                                signal_session_pgids(pid, libc::SIGKILL, false);
2512                            } else {
2513                                let _ = if target_is_group {
2514                                    process.kill_group(pgid, libc::SIGKILL)
2515                                } else {
2516                                    process.kill(libc::SIGKILL)
2517                                };
2518                            }
2519                            state = KillState::KillSent;
2520                            kill_sent_at = Some(Instant::now());
2521                        }
2522                        // Bounded re-enumeration for a pty session: a group
2523                        // created after the first snapshot is still a slave
2524                        // holder and keeps the master open; SIGKILL it. The
2525                        // D-state give-up bound below caps the /proc cost.
2526                        KillState::KillSent if pty && !drain.is_done() => {
2527                            signal_session_pgids(pid, libc::SIGKILL, false);
2528                        }
2529                        _ => {}
2530                    }
2531                }
2532                poll_timeout = 100; // Poll frequently while waiting for kill to take effect
2533            } else {
2534                let remaining = dl - elapsed;
2535                poll_timeout = remaining.as_millis().min(i32::MAX as u128) as i32;
2536            }
2537        }
2538
2539        if status_raw.is_none()
2540            && let Some(s) = process.wait_step()?
2541        {
2542            status_raw = Some(s);
2543        }
2544
2545        // F5: natural completion for a contained session under `Sweep`
2546        // triggers on leader-reap, not master EOF. A slave-holding background
2547        // member keeps the master open, so EOF alone would hang completion
2548        // forever and the sweep (gated behind `drain.is_done`) would never run
2549        // (finding A4-3). Sweep every tick once the leader is reaped; the
2550        // SIGKILL releases the slave so EOF can fire, and the completion path
2551        // below stays gated on an empty session + drain.
2552        if status_raw.is_some()
2553            && (pty || pgroup.isolated)
2554            && cancel != CancelPolicy::None
2555            && session_exit == SessionExitPolicy::Sweep
2556            && !drain.is_done()
2557        {
2558            if !session_sweep(pid) && sweep_started_at.is_none() {
2559                sweep_started_at = Some(Instant::now());
2560            }
2561        }
2562
2563        // F5: `LetMembersSurvive` reports natural completion on leader-reap
2564        // without signaling the session (nohup-style background jobs keep
2565        // running). The master may still be open — force-close the drain and
2566        // return the partial output with the reaped status.
2567        if status_raw.is_some()
2568            && (pty || pgroup.isolated)
2569            && cancel != CancelPolicy::None
2570            && session_exit == SessionExitPolicy::LetMembersSurvive
2571            && !timed_out
2572        {
2573            for slot in drain.take_all_slots() {
2574                if slot.token.is_some() {
2575                    let _ = reactor.del(&slot.fd);
2576                }
2577            }
2578            let stdout_pending = drain.take_stdout_pending();
2579            let stderr_pending = drain.take_stderr_pending();
2580            let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
2581                drain.into_parts_with_state();
2582            return Ok(Output {
2583                pid,
2584                status: status_raw.take(),
2585                stdout,
2586                stderr,
2587                timed_out,
2588                stdout_early_exited,
2589                stdout_pending,
2590                stderr_pending,
2591            });
2592        }
2593
2594        if drain.is_done() {
2595            let s = if status_raw.is_some() {
2596                status_raw.take()
2597            } else if deadline.is_none() {
2598                // C1: all pipes drained but the child is still alive, and no
2599                // deadline is set → block until it exits (intended semantics).
2600                Some(process.wait_blocking()?)
2601            } else {
2602                // C1: pipes drained with a deadline set → never block here; fall
2603                // through to the bounded `reactor.wait` below so the deadline
2604                // logic at the top of the loop kills and reaps. A later
2605                // `wait_step` reaps the child and we return from this branch.
2606                None
2607            };
2608
2609            if let Some(s) = s {
2610                // H6/F5: EOF + leader reap must not be reported while contained
2611                // session members survive under `Sweep` — a detached descendant
2612                // would outlive a job reported exit-0 (finding H6). Sweep the
2613                // isolated session until /proc shows no live members before
2614                // completing. `LetMembersSurvive` completes on leader-reap
2615                // without signaling (handled by the earlier branch; this is the
2616                // EOF-first path where the leader may still be alive).
2617                // `CancelPolicy::None` opted out of all signaling and keeps the
2618                // legacy EOF-based completion.
2619                let sweep_needed = (pty || pgroup.isolated)
2620                    && cancel != CancelPolicy::None
2621                    && session_exit == SessionExitPolicy::Sweep
2622                    && !session_sweep(pid);
2623                if sweep_needed {
2624                    status_raw = Some(s);
2625                    // The pipes are already EOF'd, so there are no further
2626                    // readiness events; bound the reactor wait so the loop
2627                    // re-scans the session at the existing 10 ms cadence
2628                    // without a raw thread sleep (a D-state member keeps the
2629                    // SIGKILL pending until it wakes — H2 seam).
2630                    if sweep_started_at.is_none() {
2631                        sweep_started_at = Some(Instant::now());
2632                    }
2633                    poll_timeout = 10;
2634                } else {
2635                    for slot in drain.take_all_slots() {
2636                        if slot.token.is_some() {
2637                            reactor.del(&slot.fd)?;
2638                        }
2639                    }
2640                    let stdout_pending = drain.take_stdout_pending();
2641                    let stderr_pending = drain.take_stderr_pending();
2642                    let (stdout, stderr, output_limit_exceeded, stdout_early_exited) =
2643                        drain.into_parts_with_state();
2644                    if output_limit_exceeded {
2645                        return Err(CoreError::sys(libc::EOVERFLOW, "spawn output limit"));
2646                    }
2647                    return Ok(Output {
2648                        pid,
2649                        status: Some(s),
2650                        stdout,
2651                        stderr,
2652                        timed_out,
2653                        stdout_early_exited,
2654                        stdout_pending,
2655                        stderr_pending,
2656                    });
2657                }
2658            }
2659        }
2660
2661        // Streaming mode: a paused stream (full sink queue) cannot progress
2662        // even after the child is reaped — the fd is not registered, so no
2663        // readiness event will ever arrive. Return the partial output and the
2664        // held pending chunk for the caller to flush (the blocking-path mirror
2665        // of `poll_completion`'s paused-finish branch).
2666        if status_raw.is_some() && (drain.stdout_paused() || drain.stderr_paused()) {
2667            // H1/H6/F5: a paused stream + reaped leader must also wait for the
2668            // session to empty before reporting completion under `Sweep` (the
2669            // wait_loop mirror of `poll_completion`'s `io_done || paused`
2670            // gate); `LetMembersSurvive` never sweeps, so it completes here.
2671            if (pty || pgroup.isolated)
2672                && cancel != CancelPolicy::None
2673                && session_exit == SessionExitPolicy::Sweep
2674                && !session_sweep(pid)
2675            {
2676                if sweep_started_at.is_none() {
2677                    sweep_started_at = Some(Instant::now());
2678                }
2679                // Fall through to the bounded reactor wait below: the paused
2680                // stream produces no readiness events, and the backpressure
2681                // block bounds the poll at 10 ms, re-scanning the session on
2682                // the existing cadence without a raw thread sleep.
2683                poll_timeout = 10;
2684            } else {
2685                for slot in drain.take_all_slots() {
2686                    if slot.token.is_some() {
2687                        let _ = reactor.del(&slot.fd);
2688                    }
2689                }
2690                let stdout_pending = drain.take_stdout_pending();
2691                let stderr_pending = drain.take_stderr_pending();
2692                let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
2693                    drain.into_parts_with_state();
2694                return Ok(Output {
2695                    pid,
2696                    status: status_raw,
2697                    stdout,
2698                    stderr,
2699                    timed_out,
2700                    stdout_early_exited,
2701                    stdout_pending,
2702                    stderr_pending,
2703                });
2704            }
2705        }
2706
2707        // N4: the deadline has elapsed and the child is reaped, but a wedged
2708        // pipe (a descendant inheriting the write end) keeps the drain from
2709        // closing. The absolute deadline is authoritative — return the partial
2710        // output instead of spinning forever. For a pty session the master is
2711        // the drain, and "wedged" means a background pgrp still holds the
2712        // slave: the session kill loop must get its bounded chance first, so
2713        // only finish on master EOF or after the D-state give-up bound.
2714        if timed_out && status_raw.is_some() {
2715            let can_finish = if pty {
2716                if cancel == CancelPolicy::None {
2717                    drain.is_done()
2718                        || kill_sent_at.is_some_and(|t| t.elapsed() >= D_STATE_REAP_BOUND)
2719                } else {
2720                    // H2: the D-state bound must not return the timed-out
2721                    // result while the master is open and session members
2722                    // remain — the bound only proves SIGKILL was sent 500 ms
2723                    // ago. Hold until the sweep empties the session (a
2724                    // D-state member keeps SIGKILL pending until it wakes).
2725                    (drain.is_done()
2726                        || kill_sent_at.is_some_and(|t| t.elapsed() >= D_STATE_REAP_BOUND))
2727                        && session_sweep(pid)
2728                }
2729            } else if pgroup.isolated && cancel != CancelPolicy::None {
2730                // H4: the group kill stopped when the leader was reaped, but
2731                // contained descendants (e.g. the member holding the pipe
2732                // write end) survive — sweep the session until /proc shows no
2733                // live members before returning the timed-out result
2734                // (kill-totality, finding H4). A D-state member keeps the
2735                // SIGKILL pending until it wakes (H2 seam).
2736                session_sweep(pid)
2737            } else {
2738                true
2739            };
2740            if can_finish {
2741                for slot in drain.take_all_slots() {
2742                    if slot.token.is_some() {
2743                        let _ = reactor.del(&slot.fd);
2744                    }
2745                }
2746                let stdout_pending = drain.take_stdout_pending();
2747                let stderr_pending = drain.take_stderr_pending();
2748                let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
2749                    drain.into_parts_with_state();
2750                return Ok(Output {
2751                    pid,
2752                    status: status_raw,
2753                    stdout,
2754                    stderr,
2755                    timed_out: true,
2756                    stdout_early_exited,
2757                    stdout_pending,
2758                    stderr_pending,
2759                });
2760            }
2761        }
2762
2763        // D-state / sweep give-up (F6): SIGKILL has been sent but the child is
2764        // still unreaped after the bound, OR the natural-path session sweep has
2765        // been running past the bound without converging (a D-state member
2766        // keeps SIGKILL pending until it wakes). A child stuck in
2767        // uninterruptible sleep keeps the signal pending until it leaves
2768        // D-state, so no further wait can succeed — return the partial output
2769        // rather than polling forever. The pid is not signaled again (it may
2770        // be recycled once it finally exits).
2771        let kill_gave_up = kill_sent_at
2772            .is_some_and(|sent_at| sent_at.elapsed() >= D_STATE_REAP_BOUND)
2773            && status_raw.is_none();
2774        let sweep_gave_up = sweep_started_at.is_some_and(|t| t.elapsed() >= D_STATE_REAP_BOUND);
2775        if kill_gave_up || sweep_gave_up {
2776            for slot in drain.take_all_slots() {
2777                if slot.token.is_some() {
2778                    let _ = reactor.del(&slot.fd);
2779                }
2780            }
2781            // H2: the give-up is about the unreapable *leader* or an
2782            // unkillable *member*; others may still be alive, and stopping the
2783            // sweep here would leak them. Hand the session to the detached
2784            // reaper (safe: the live leader pins the sid; a reaped leader
2785            // makes the starttime-gated `orphan_session` a safe no-op), which
2786            // keeps SIGKILLing until /proc empties (finding H2). The pending
2787            // SIGKILL dies when the member wakes.
2788            if pty || pgroup.isolated {
2789                orphan_session(pid);
2790            }
2791            // The child is unreapable right now but will eventually leave
2792            // D-state and exit; nobody will wait on it after this give-up, so
2793            // hand it to the reaper (finding 15). A reaped leader is already
2794            // gone — skip the re-registration.
2795            if status_raw.is_none() {
2796                orphan_child(pid);
2797            }
2798            let stdout_pending = drain.take_stdout_pending();
2799            let stderr_pending = drain.take_stderr_pending();
2800            let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
2801                drain.into_parts_with_state();
2802            return Ok(Output {
2803                pid,
2804                status: status_raw,
2805                stdout,
2806                stderr,
2807                timed_out,
2808                stdout_early_exited,
2809                stdout_pending,
2810                stderr_pending,
2811            });
2812        }
2813
2814        // `CancelPolicy::None`: the deadline elapsed but nothing was ever
2815        // signaled, so the child may stay wedged (pipe held by a descendant,
2816        // child unreaped) indefinitely. Give up with the partial output after
2817        // the same bound as the D-state path — otherwise this polls at 100 ms
2818        // forever (finding 14).
2819        if cancel == CancelPolicy::None
2820            && timed_out
2821            && status_raw.is_none()
2822            && deadline_passed_at.is_some_and(|passed| passed.elapsed() >= D_STATE_REAP_BOUND)
2823        {
2824            for slot in drain.take_all_slots() {
2825                if slot.token.is_some() {
2826                    let _ = reactor.del(&slot.fd);
2827                }
2828            }
2829            // The child was never signaled and may still be running; nobody
2830            // will wait on it now — hand it to the reaper (finding 15).
2831            orphan_child(pid);
2832            let stdout_pending = drain.take_stdout_pending();
2833            let stderr_pending = drain.take_stderr_pending();
2834            let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
2835                drain.into_parts_with_state();
2836            return Ok(Output {
2837                pid,
2838                status: None,
2839                stdout,
2840                stderr,
2841                timed_out: true,
2842                stdout_early_exited,
2843                stdout_pending,
2844                stderr_pending,
2845            });
2846        }
2847
2848        // Streaming backpressure: while a stream is paused its fd is not
2849        // registered (no readiness events). Keep trying to resume so a
2850        // concurrent queue consumer's drained capacity re-registers the fd,
2851        // and bound the poll so the loop cannot block forever on a paused
2852        // stream.
2853        if drain.stdout_paused() || drain.stderr_paused() {
2854            if drain.stdout_paused() {
2855                let _ = drain.resume_stdout(&mut reactor);
2856            }
2857            if drain.stderr_paused() {
2858                let _ = drain.resume_stderr(&mut reactor);
2859            }
2860            if !(0..=10).contains(&poll_timeout) {
2861                poll_timeout = 10;
2862            }
2863        }
2864
2865        let timeout = poll_timeout;
2866
2867        let mut events = Vec::new();
2868        let nevents = reactor.wait(&mut events, 64, timeout)?;
2869
2870        for ev in events.iter().take(nevents) {
2871            if drain.stdout_matches(ev.token) {
2872                if ev.readable || ev.hangup {
2873                    drain.handle_stdout_ready(&mut reactor)?;
2874                } else if ev.error {
2875                    drain.drop_stdout(&mut reactor)?;
2876                }
2877            } else if drain.stderr_matches(ev.token) {
2878                if ev.readable || ev.hangup {
2879                    drain.handle_stderr_ready(&mut reactor)?;
2880                } else if ev.error {
2881                    drain.drop_stderr(&mut reactor)?;
2882                }
2883            } else if drain.stdin_matches(ev.token) {
2884                if ev.writable {
2885                    drain.handle_stdin_writable(&mut reactor)?;
2886                } else if ev.error || ev.hangup {
2887                    drain.drop_stdin(&mut reactor)?;
2888                }
2889            }
2890        }
2891    }
2892}