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