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