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