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
277struct Pipes {
278 stdin_r: Option<Fd>,
279 stdin_w: Option<Fd>,
280 stdout_r: Option<Fd>,
281 stdout_w: Option<Fd>,
282 stderr_r: Option<Fd>,
283 stderr_w: Option<Fd>,
284}
285
286impl Pipes {
287 fn new(in_buf: Option<&[u8]>, out: bool, err: bool) -> Result<Self, CoreError> {
288 let (stdin_r, stdin_w) = if in_buf.is_some() {
289 let (r, w) = make_pipe()?;
290 (Some(r), Some(w))
291 } else {
292 (None, None)
293 };
294
295 let (stdout_r, stdout_w) = if out {
296 let (r, w) = make_pipe()?;
297 (Some(r), Some(w))
298 } else {
299 (None, None)
300 };
301
302 let (stderr_r, stderr_w) = if err {
303 let (r, w) = make_pipe()?;
304 (Some(r), Some(w))
305 } else {
306 (None, None)
307 };
308
309 Ok(Self {
310 stdin_r,
311 stdin_w,
312 stdout_r,
313 stdout_w,
314 stderr_r,
315 stderr_w,
316 })
317 }
318
319 #[inline(always)]
320 fn close_all(&mut self) {
321 self.stdin_r.take();
322 self.stdin_w.take();
323 self.stdout_r.take();
324 self.stdout_w.take();
325 self.stderr_r.take();
326 self.stderr_w.take();
327 }
328}
329
330/// Represents the termination status of a process.
331#[derive(Debug, PartialEq, Eq)]
332pub enum ExitStatus {
333 /// Process exited normally with the specified code.
334 Exited(i32),
335 /// Process was terminated by a signal.
336 Signaled(i32),
337}
338
339/// Explicit process spawning backend.
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub enum SpawnBackend {
342 /// Force the use of `posix_spawn`.
343 PosixSpawn,
344 /// Force the use of `fork`/`exec`.
345 ///
346 /// The fork backend supports explicit [`SpawnFdPolicy`] handling before
347 /// `execve`.
348 Fork,
349 /// Force the use of `vfork`/`exec`.
350 ///
351 /// `vfork` shares the parent's address space with the child until it
352 /// `execve`s (or `_exit`s), so it avoids the page-table work of `fork`.
353 /// The child runs only async-signal-safe setup before `execve`, and the
354 /// calling thread is blocked until the child execs. Safe for the child
355 /// because the Linux `vfork` child inherits a *copy* of the descriptor
356 /// table, so [`SpawnFdPolicy`] handling works as with [`SpawnBackend::Fork`].
357 ///
358 /// Use only when the shared-address-space semantics are understood:
359 /// the child must never return from the spawn entry point, and a bug in the
360 /// child setup can corrupt the parent's memory.
361 Vfork,
362 /// Force the use of `clone3(2)`/`exec` (kernel 5.3+).
363 ///
364 /// `clone3` with process flags creates a child with copy-on-write memory
365 /// and a copied descriptor table, like [`SpawnBackend::Fork`], but lets the
366 /// caller control clone flags directly. Supported by the same child setup
367 /// as the fork backend. Returns `ENOSYS` on kernels without `clone3`.
368 Clone3,
369 /// Force the use of `clone3(2)` with `CLONE_PIDFD` + `exec` (kernel 5.3+).
370 ///
371 /// Identical to [`SpawnBackend::Clone3`], but the kernel additionally hands
372 /// the parent a pidfd for the child. The resulting [`Process`] carries that
373 /// pidfd: signaling uses `pidfd_send_signal` (immune to pid reuse), and
374 /// exit detection `poll`s the pidfd instead of polling `waitpid`. Returns
375 /// `ENOSYS` on kernels without `clone3`.
376 Clone3Pidfd,
377}
378
379/// Explicit file-descriptor inheritance policy for spawned children.
380#[derive(Debug, Clone, PartialEq, Eq, Default)]
381pub enum SpawnFdPolicy {
382 /// Inherit descriptors according to their existing `FD_CLOEXEC` flags.
383 #[default]
384 CloexecOnly,
385 /// For the fork backend, close every descriptor >= 3 before `execve`,
386 /// except Core-required pipe descriptors.
387 CloseFrom3,
388 /// For the fork backend, close every descriptor >= 3 before `execve`,
389 /// except Core-required pipe descriptors and the listed descriptors.
390 ///
391 /// Core does not close allowlisted descriptors, but their existing
392 /// `FD_CLOEXEC` state still applies. Callers that want an allowlisted
393 /// descriptor to survive `execve` must clear `FD_CLOEXEC` before spawning.
394 Allowlist(Vec<RawFd>),
395}
396
397#[inline(always)]
398fn decode_status(status: i32) -> ExitStatus {
399 if WIFEXITED(status) {
400 ExitStatus::Exited(WEXITSTATUS(status))
401 } else if WIFSIGNALED(status) {
402 ExitStatus::Signaled(WTERMSIG(status))
403 } else {
404 ExitStatus::Exited(-1)
405 }
406}
407
408/// A handle to a spawned process.
409///
410/// ### Fork Safety
411/// The process handle contains a PID. After a `fork`, the child process will
412/// have a copy of this PID, but it refers to the same original process.
413/// Calling `wait` or `kill` from the child may lead to confusing results
414/// if multiple processes are managing the same PID.
415///
416/// When the process was spawned by [`SpawnBackend::Clone3Pidfd`], the handle
417/// additionally owns the child's pidfd. Signaling then uses
418/// `pidfd_send_signal`, which cannot race with pid reuse, and exit detection
419/// `poll`s the pidfd. The pidfd is closed when the handle is dropped.
420pub struct Process {
421 pid: pid_t,
422 pidfd: Option<RawFd>,
423}
424
425impl Process {
426 /// Create a handle for an existing PID (no pidfd).
427 pub fn new(pid: pid_t) -> Self {
428 Self { pid, pidfd: None }
429 }
430
431 /// Create a handle for an existing PID that also owns its pidfd.
432 pub(crate) fn with_pidfd(pid: pid_t, pidfd: RawFd) -> Self {
433 Self {
434 pid,
435 pidfd: Some(pidfd),
436 }
437 }
438
439 /// Return the process ID.
440 pub fn pid(&self) -> pid_t {
441 self.pid
442 }
443
444 /// Return the pidfd owned by this handle, if any.
445 pub fn pidfd(&self) -> Option<RawFd> {
446 self.pidfd
447 }
448
449 /// Perform a non-blocking wait for process termination.
450 ///
451 /// When the handle owns a pidfd, the wait first `poll`s the pidfd (which
452 /// becomes readable exactly when the child exits) and then reaps with
453 /// `waitpid`, avoiding the `ECHILD`-race of polling `waitpid` directly.
454 ///
455 /// ### Errors
456 /// - `ECHILD`: The process does not exist or is not a child of the caller.
457 /// - `EINTR`: The call was interrupted by a signal (handled internally).
458 pub fn wait_step(&self) -> Result<Option<ExitStatus>, CoreError> {
459 if let Some(pidfd) = self.pidfd {
460 return wait_step_pidfd(pidfd, self.pid);
461 }
462 loop {
463 let mut status = 0;
464 let r = unsafe { waitpid(self.pid, &mut status, libc::WNOHANG) };
465 if r == 0 {
466 return Ok(None);
467 }
468 if r < 0 {
469 let e = errno();
470 if e == libc::EINTR {
471 continue;
472 }
473 return Err(CoreError::sys(e, "waitpid_step"));
474 }
475 return Ok(Some(decode_status(status)));
476 }
477 }
478
479 /// Block until the process terminates.
480 ///
481 /// ### Errors
482 /// - `ECHILD`: The process does not exist or is not a child of the caller.
483 pub fn wait_blocking(&self) -> Result<ExitStatus, CoreError> {
484 loop {
485 let mut status = 0;
486 let r = unsafe { waitpid(self.pid, &mut status, 0) };
487 if r < 0 {
488 let e = errno();
489 if e == libc::EINTR {
490 continue;
491 }
492 return Err(CoreError::sys(e, "waitpid_blocking"));
493 }
494 return Ok(decode_status(status));
495 }
496 }
497
498 /// Send a signal to the process.
499 ///
500 /// When the handle owns a pidfd, the signal is delivered with
501 /// `pidfd_send_signal`, which cannot target a recycled pid; on kernels
502 /// without it (`ENOSYS`, kernel < 5.1) it falls back to `kill`.
503 ///
504 /// ### Errors
505 /// - `EINVAL`: Invalid signal number, or a non-positive pid (pid `0`
506 /// would signal the caller's own process group). With a pidfd,
507 /// `pidfd_send_signal` returns `EINVAL` for an invalid signal and this
508 /// is reported, not downgraded to a `kill` fallback.
509 /// - `EPERM`: The caller does not have permission to send the signal.
510 /// - `ESRCH`: The process does not exist.
511 pub fn kill(&self, sig: i32) -> Result<(), CoreError> {
512 if let Some(pidfd) = self.pidfd {
513 let r = unsafe {
514 libc::syscall(
515 SYS_PIDFD_SEND_SIGNAL,
516 pidfd,
517 sig,
518 std::ptr::null_mut::<libc::siginfo_t>(),
519 0,
520 )
521 };
522 if r < 0 {
523 let e = errno();
524 if e == libc::ESRCH {
525 return Ok(());
526 }
527 // `pidfd_send_signal` returns EINVAL for an invalid signal
528 // number or an unsupported flag — falling back to `kill` on
529 // EINVAL would change semantics (e.g. signal 0 becomes an
530 // existence check). Only a kernel that lacks the syscall
531 // entirely (ENOSYS, pre-5.1) warrants the `kill` fallback.
532 if e != libc::ENOSYS {
533 return Err(CoreError::sys(e, "pidfd_send_signal"));
534 }
535 // Kernel lacks pidfd_send_signal; fall through to kill.
536 } else {
537 return Ok(());
538 }
539 }
540 if self.pid <= 0 {
541 return Err(CoreError::sys(libc::EINVAL, "kill: invalid pid"));
542 }
543 let r = unsafe { libc::kill(self.pid, sig) };
544 if r < 0 {
545 let e = errno();
546 if e == libc::ESRCH {
547 return Ok(());
548 }
549 syscall_ret(-1, "kill")?;
550 }
551 Ok(())
552 }
553
554 /// Signal the process group whose id equals [`Self::pid`] — valid only
555 /// when the process is its own group/session leader. For a child placed
556 /// into a custom leader's group use [`Self::kill_group`].
557 ///
558 /// ### Errors
559 /// Same as [`Self::kill`].
560 pub fn kill_pgroup(&self, sig: i32) -> Result<(), CoreError> {
561 self.kill_group(self.pid, sig)
562 }
563
564 /// Send a signal to an explicit process group.
565 ///
566 /// The pgid must be the child's actual group (its own pid after `setsid`,
567 /// or the configured leader's id after `setpgid`), never guessed from the
568 /// pid, and never `0` or negative — `kill(-0)` would signal the caller's
569 /// own process group.
570 ///
571 /// ### Errors
572 /// Same as [`Self::kill`], plus `EINVAL` for a non-positive pgid.
573 pub fn kill_group(&self, pgid: pid_t, sig: i32) -> Result<(), CoreError> {
574 if pgid <= 0 {
575 return Err(CoreError::sys(libc::EINVAL, "kill_group: invalid pgid"));
576 }
577 let r = unsafe { libc::kill(-pgid, sig) };
578 if r < 0 {
579 let e = errno();
580 if e == libc::ESRCH {
581 return Ok(());
582 }
583 syscall_ret(-1, "kill_group")?;
584 }
585 Ok(())
586 }
587}
588
589impl Drop for Process {
590 fn drop(&mut self) {
591 if let Some(pidfd) = self.pidfd.take() {
592 unsafe {
593 libc::close(pidfd);
594 }
595 }
596 }
597}
598
599/// Non-blocking exit wait using a pidfd: `poll(2)` on the pidfd becomes
600/// readable exactly when the child exits, and reaping still uses `waitpid`
601/// (our own child cannot be pid-recycled while it is unreaped). Returns
602/// `Ok(None)` while the child is running or was already reaped.
603fn wait_step_pidfd(pidfd: RawFd, pid: pid_t) -> Result<Option<ExitStatus>, CoreError> {
604 let mut pfd = libc::pollfd {
605 fd: pidfd,
606 events: libc::POLLIN,
607 revents: 0,
608 };
609 loop {
610 let r = unsafe { libc::poll(&mut pfd, 1, 0) };
611 if r < 0 {
612 let e = errno();
613 if e == libc::EINTR {
614 continue;
615 }
616 return Err(CoreError::sys(e, "poll(pidfd)"));
617 }
618 break;
619 }
620 if pfd.revents & libc::POLLIN == 0 {
621 return Ok(None);
622 }
623 loop {
624 let mut status = 0;
625 let r = unsafe { waitpid(pid, &mut status, libc::WNOHANG) };
626 if r == pid {
627 return Ok(Some(decode_status(status)));
628 }
629 if r < 0 {
630 let e = errno();
631 if e == libc::EINTR {
632 continue;
633 }
634 if e == libc::ECHILD {
635 // Reaped elsewhere; the pidfd stays readable.
636 return Ok(None);
637 }
638 return Err(CoreError::sys(e, "waitpid(pidfd step)"));
639 }
640 // r == 0: readiness raced with a concurrent reap; not running now.
641 return Ok(None);
642 }
643}
644
645/// Configuration options for spawning a new process.
646#[derive(Clone)]
647pub struct SpawnOptions {
648 ctx: ExecContext,
649 stdin: Option<Box<[u8]>>,
650 capture_stdout: bool,
651 capture_stderr: bool,
652 wait: bool,
653 pgroup: ProcessGroup,
654 session_containment: bool,
655 max_output: usize,
656 timeout_ms: Option<u32>,
657 kill_grace_ms: u32,
658 cancel: CancelPolicy,
659 backend: SpawnBackend,
660 fd_policy: SpawnFdPolicy,
661 early_exit: Option<fn(&[u8]) -> bool>,
662 /// Optional streaming chunk observer: every retained output chunk is
663 /// forwarded here as it is read (`is_stdout`, bytes) instead of being
664 /// accumulated for the completion [`Output`]. Return [`SinkResult::Pause`]
665 /// to stop draining (the chunk is retained and re-delivered on resume);
666 /// bytes are never dropped on this path and the read loop never blocks.
667 /// Ignored when the stream is not captured.
668 chunk_sink: Option<ChunkSink>,
669}
670
671impl SpawnOptions {
672 /// Create a new builder for process spawning.
673 pub fn builder(argv: Vec<String>, backend: SpawnBackend) -> SpawnOptionsBuilder {
674 SpawnOptionsBuilder::new(argv, backend)
675 }
676
677 /// Execute the process according to the options and block until completion.
678 pub fn run(self) -> Result<Output, CoreError> {
679 spawn(self)
680 }
681}
682
683/// Builder for [`SpawnOptions`].
684#[derive(Clone)]
685pub struct SpawnOptionsBuilder {
686 argv: Vec<String>,
687 env: Option<Vec<String>>,
688 cwd: Option<String>,
689 stdin: Option<Box<[u8]>>,
690 capture_stdout: bool,
691 capture_stderr: bool,
692 wait: bool,
693 pgroup: ProcessGroup,
694 session_containment: bool,
695 max_output: usize,
696 timeout_ms: Option<u32>,
697 kill_grace_ms: u32,
698 cancel: CancelPolicy,
699 backend: SpawnBackend,
700 fd_policy: SpawnFdPolicy,
701 early_exit: Option<fn(&[u8]) -> bool>,
702 chunk_sink: Option<ChunkSink>,
703}
704
705impl SpawnOptionsBuilder {
706 /// Create a new builder with the specified argument vector.
707 pub fn new(argv: Vec<String>, backend: SpawnBackend) -> Self {
708 Self {
709 argv,
710 env: None,
711 cwd: None,
712 stdin: None,
713 capture_stdout: false,
714 capture_stderr: false,
715 wait: true,
716 pgroup: ProcessGroup::default(),
717 session_containment: false,
718 max_output: 1024 * 1024,
719 timeout_ms: None,
720 kill_grace_ms: 2000,
721 cancel: CancelPolicy::Kill,
722 backend,
723 fd_policy: SpawnFdPolicy::default(),
724 early_exit: None,
725 chunk_sink: None,
726 }
727 }
728
729 /// Set environment variables.
730 pub fn env(mut self, env: Vec<String>) -> Self {
731 self.env = Some(env);
732 self
733 }
734
735 /// Set the working directory.
736 pub fn cwd(mut self, cwd: String) -> Self {
737 self.cwd = Some(cwd);
738 self
739 }
740
741 /// Provide data to be written to the child's stdin.
742 pub fn stdin(mut self, data: impl Into<Box<[u8]>>) -> Self {
743 self.stdin = Some(data.into());
744 self
745 }
746
747 /// Enable stdout capture.
748 pub fn capture_stdout(mut self) -> Self {
749 self.capture_stdout = true;
750 self
751 }
752
753 /// Enable stderr capture.
754 pub fn capture_stderr(mut self) -> Self {
755 self.capture_stderr = true;
756 self
757 }
758
759 /// Set whether to wait for the process to terminate (default: true).
760 pub fn wait(mut self, wait: bool) -> Self {
761 self.wait = wait;
762 self
763 }
764
765 /// Set process group and isolation policy.
766 pub fn pgroup(mut self, pgroup: ProcessGroup) -> Self {
767 self.pgroup = pgroup;
768 self
769 }
770
771 /// Contain the child inside the process group/session it is placed into.
772 ///
773 /// A seccomp filter installed in the child (after the daemon's own
774 /// `setsid`/`setpgid`, before `execve`) denies `setsid`, `setpgid`,
775 /// `setpgrp`, `unshare`, and `setns`. Because filters are inherited
776 /// across `fork` and `execve` and can only be tightened, never loosened,
777 /// the child and every descendant are locked into the group/session —
778 /// making `kill_group` (timeout/cancel deactivation) total even against a
779 /// hostile root child that tries to escape by daemonizing or changing its
780 /// process group. Requires an isolated process group
781 /// ([`ProcessGroup::new(None, true)`](ProcessGroup::new)); rejected on
782 /// [`SpawnBackend::PosixSpawn`](SpawnBackend::PosixSpawn), which has no
783 /// child setup step.
784 pub fn session_containment(mut self) -> Self {
785 self.session_containment = true;
786 self
787 }
788
789 /// Set the combined stdout+stderr output buffer size (default: 1MB).
790 ///
791 /// If captured output exceeds this limit, spawn drains the child pipes to
792 /// completion and returns `EOVERFLOW`.
793 pub fn max_output(mut self, max: usize) -> Self {
794 self.max_output = max;
795 self
796 }
797
798 /// Set the execution timeout in milliseconds.
799 pub fn timeout_ms(mut self, ms: u32) -> Self {
800 self.timeout_ms = Some(ms);
801 self
802 }
803
804 /// Set the grace period before SIGKILL (default: 2s).
805 pub fn kill_grace_ms(mut self, ms: u32) -> Self {
806 self.kill_grace_ms = ms;
807 self
808 }
809
810 /// Set the cancellation policy (default: Kill).
811 pub fn cancel(mut self, policy: CancelPolicy) -> Self {
812 self.cancel = policy;
813 self
814 }
815
816 /// Set the child file-descriptor inheritance policy.
817 pub fn fd_policy(mut self, policy: SpawnFdPolicy) -> Self {
818 self.fd_policy = policy;
819 self
820 }
821
822 /// Set an early exit callback.
823 pub fn early_exit(mut self, callback: fn(&[u8]) -> bool) -> Self {
824 self.early_exit = Some(callback);
825 self
826 }
827
828 /// Enable streaming drain: forward every retained output chunk to `sink`
829 /// as it is read instead of accumulating it for the completion [`Output`].
830 ///
831 /// The sink returns [`SinkResult::Pause`] when its bounded queue is full;
832 /// the drain then stops reading the child (kernel backpressure applies)
833 /// without dropping the held chunk and without blocking the reactor.
834 /// Resume via the managed-process or drain resume methods once the queue
835 /// drains. When a sink is set, `max_output` no longer truncates: bytes
836 /// are never dropped on the streaming path.
837 pub fn chunk_sink<F>(mut self, sink: F) -> Self
838 where
839 F: Fn(bool, &[u8]) -> SinkResult + Send + Sync + 'static,
840 {
841 self.chunk_sink = Some(Arc::new(sink));
842 self
843 }
844
845 /// Build the spawn options.
846 pub fn build(self) -> Result<SpawnOptions, CoreError> {
847 let ctx = ExecContext::new(self.argv, self.env, self.cwd)?;
848 Ok(SpawnOptions {
849 ctx,
850 stdin: self.stdin,
851 capture_stdout: self.capture_stdout,
852 capture_stderr: self.capture_stderr,
853 wait: self.wait,
854 pgroup: self.pgroup,
855 session_containment: self.session_containment,
856 max_output: self.max_output,
857 timeout_ms: self.timeout_ms,
858 kill_grace_ms: self.kill_grace_ms,
859 cancel: self.cancel,
860 backend: self.backend,
861 fd_policy: self.fd_policy,
862 early_exit: self.early_exit,
863 chunk_sink: self.chunk_sink,
864 })
865 }
866}
867
868/// The result of a process execution.
869#[derive(Debug)]
870pub struct Output {
871 /// The PID of the finished process.
872 pub pid: pid_t,
873 /// Final exit status (None if `wait=false`).
874 pub status: Option<ExitStatus>,
875 /// Captured stdout buffer.
876 pub stdout: Vec<u8>,
877 /// Captured stderr buffer.
878 pub stderr: Vec<u8>,
879 /// Whether the process timed out.
880 pub timed_out: bool,
881 /// Whether stdout drain stopped because the early-exit callback matched.
882 pub stdout_early_exited: bool,
883 /// Streaming mode: the stdout chunk held while the sink queue was full at
884 /// completion (empty/none when no sink was attached). The caller must
885 /// flush it before delivering the terminal frame.
886 pub stdout_pending: Option<Vec<u8>>,
887 /// Streaming mode: the stderr chunk held while the sink queue was full at
888 /// completion.
889 pub stderr_pending: Option<Vec<u8>>,
890}
891
892fn validate_backend(opts: &SpawnOptions) -> Result<(), CoreError> {
893 validate_fd_policy(&opts.fd_policy)?;
894 match opts.backend {
895 SpawnBackend::PosixSpawn => {
896 if opts.ctx.cwd.is_some() {
897 return Err(CoreError::sys(libc::EINVAL, "posix_spawn cwd unsupported"));
898 }
899 if opts.pgroup.isolated {
900 return Err(CoreError::sys(
901 libc::EINVAL,
902 "posix_spawn setsid unsupported",
903 ));
904 }
905 if opts.session_containment {
906 return Err(CoreError::sys(
907 libc::EINVAL,
908 "posix_spawn session containment unsupported",
909 ));
910 }
911 if opts.fd_policy != SpawnFdPolicy::CloexecOnly {
912 return Err(CoreError::sys(
913 libc::EINVAL,
914 "posix_spawn fd policy unsupported",
915 ));
916 }
917 Ok(())
918 }
919 SpawnBackend::Fork
920 | SpawnBackend::Vfork
921 | SpawnBackend::Clone3
922 | SpawnBackend::Clone3Pidfd => {
923 // After `setsid` the child is a session leader in a brand-new
924 // session; `setpgid(0, leader)` for a leader outside that session
925 // always fails with EPERM. A zero leader means "own pid" (the
926 // child's own group after setsid), which is valid. Applies to
927 // every exec-style backend: they all run the same child setup.
928 if opts.pgroup.isolated && opts.pgroup.leader.is_some_and(|l| l != 0) {
929 return Err(CoreError::sys(
930 libc::EINVAL,
931 "exec isolated + custom setpgid leader unsupported",
932 ));
933 }
934 // Session containment pins the child to the group/session the
935 // daemon placed it in; without isolation there is no such
936 // boundary to pin to.
937 if opts.session_containment && !opts.pgroup.isolated {
938 return Err(CoreError::sys(
939 libc::EINVAL,
940 "session containment requires an isolated process group",
941 ));
942 }
943 Ok(())
944 }
945 }
946}
947
948fn validate_fd_policy(policy: &SpawnFdPolicy) -> Result<(), CoreError> {
949 if let SpawnFdPolicy::Allowlist(fds) = policy {
950 let mut seen = Vec::with_capacity(fds.len());
951 for &fd in fds {
952 if fd < 0 {
953 return Err(CoreError::sys(libc::EINVAL, "spawn fd allowlist invalid"));
954 }
955 let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
956 if flags < 0 {
957 return Err(CoreError::sys(errno(), "spawn fd allowlist fcntl(F_GETFD)"));
958 }
959 if seen.contains(&fd) {
960 return Err(CoreError::sys(libc::EINVAL, "spawn fd allowlist duplicate"));
961 }
962 seen.push(fd);
963 }
964 }
965 Ok(())
966}
967
968/// Specialized drain state for process spawning.
969pub type SpawnDrain = DrainState<fn(&[u8]) -> bool>;
970
971/// A process that is currently running and being monitored.
972///
973/// ### Fork Safety
974/// This handle contains both a PID and owned file descriptors for process I/O.
975/// Upon `fork`, the descriptors are inherited. Standard `O_CLOEXEC` behavior
976/// applies after `exec`.
977pub struct RunningProcess {
978 /// Handle to the process.
979 pub process: Process,
980 drain: SpawnDrain,
981}
982
983/// Full process lifecycle driven by a caller-owned reactor.
984///
985/// `ManagedProcess` preserves the blocking [`spawn`] semantics while allowing
986/// an application reactor to stay responsive: Core owns timeout/cancellation
987/// escalation, process-group signaling, pipe draining, overflow reporting, and
988/// `waitpid` reaping; the caller only routes readiness events and polls on
989/// [`Self::next_deadline`].
990pub struct ManagedProcess {
991 running: Option<RunningProcess>,
992 pid: pid_t,
993 timeout_at: Option<Instant>,
994 kill_grace: Duration,
995 cancel: CancelPolicy,
996 pgroup: ProcessGroup,
997 cancel_at: Option<Instant>,
998 kill_state: KillState,
999 status: Option<ExitStatus>,
1000 timed_out: bool,
1001 kill_sent_at: Option<Instant>,
1002 deadline_passed_at: Option<Instant>,
1003}
1004
1005impl RunningProcess {
1006 /// Register active stdio pipe descriptors with a reactor.
1007 ///
1008 /// Call this once after [`spawn_start`] when the process was started with
1009 /// captured output or stdin data. The assigned tokens are kept internally
1010 /// and later matched by [`Self::handle_reactor_event`].
1011 pub fn register_with_reactor(&mut self, reactor: &mut Reactor) -> Result<(), CoreError> {
1012 self.drain.register_with_reactor(reactor)
1013 }
1014
1015 /// Apply one reactor readiness event to this process' stdio drain state.
1016 ///
1017 /// Events for unrelated tokens are ignored. Callers remain responsible for
1018 /// waiting on [`Self::process`] and driving the reactor until [`Self::io_done`]
1019 /// returns true.
1020 pub fn handle_reactor_event(
1021 &mut self,
1022 reactor: &mut Reactor,
1023 event: &crate::fd::Event,
1024 ) -> Result<(), CoreError> {
1025 if self.drain.stdout_matches(event.token) {
1026 if event.readable || event.hangup {
1027 self.drain.handle_stdout_ready(reactor)?;
1028 } else if event.error {
1029 self.drain.drop_stdout(reactor)?;
1030 }
1031 } else if self.drain.stderr_matches(event.token) {
1032 if event.readable || event.hangup {
1033 self.drain.handle_stderr_ready(reactor)?;
1034 } else if event.error {
1035 self.drain.drop_stderr(reactor)?;
1036 }
1037 } else if self.drain.stdin_matches(event.token) {
1038 if event.writable {
1039 self.drain.handle_stdin_writable(reactor)?;
1040 } else if event.error || event.hangup {
1041 self.drain.drop_stdin(reactor)?;
1042 }
1043 }
1044 Ok(())
1045 }
1046
1047 /// Return whether all managed stdio pipes have been drained or closed.
1048 pub fn io_done(&self) -> bool {
1049 self.drain.is_done()
1050 }
1051
1052 /// Return whether the stdout stream is paused on a full sink queue.
1053 pub fn stdout_paused(&self) -> bool {
1054 self.drain.stdout_paused()
1055 }
1056
1057 /// Return whether the stderr stream is paused on a full sink queue.
1058 pub fn stderr_paused(&self) -> bool {
1059 self.drain.stderr_paused()
1060 }
1061
1062 /// Re-deliver the held stdout chunk (if any) and re-register the fd when
1063 /// the sink has room again. Returns `true` when the stream is resumed.
1064 pub fn resume_stdout(&mut self, reactor: &mut Reactor) -> Result<bool, CoreError> {
1065 self.drain.resume_stdout(reactor)
1066 }
1067
1068 /// Re-deliver the held stderr chunk (if any) and re-register the fd when
1069 /// the sink has room again. Returns `true` when the stream is resumed.
1070 pub fn resume_stderr(&mut self, reactor: &mut Reactor) -> Result<bool, CoreError> {
1071 self.drain.resume_stderr(reactor)
1072 }
1073
1074 /// Take the un-delivered stdout chunk (streaming mode), if any.
1075 pub(crate) fn take_stdout_pending(&mut self) -> Option<Vec<u8>> {
1076 self.drain.take_stdout_pending()
1077 }
1078
1079 /// Take the un-delivered stderr chunk (streaming mode), if any.
1080 pub(crate) fn take_stderr_pending(&mut self) -> Option<Vec<u8>> {
1081 self.drain.take_stderr_pending()
1082 }
1083
1084 /// Consume the running process handle and return captured stdout/stderr buffers.
1085 pub fn into_output_parts(self) -> (Vec<u8>, Vec<u8>) {
1086 self.drain.into_parts()
1087 }
1088}
1089
1090impl ManagedProcess {
1091 /// Return the child PID.
1092 ///
1093 /// The PID is captured at spawn time, so this remains available after the
1094 /// process has completed (unlike the running handle, which is consumed).
1095 pub fn pid(&self) -> pid_t {
1096 self.pid
1097 }
1098
1099 /// Register active child I/O descriptors with the caller's reactor.
1100 pub fn register_with_reactor(&mut self, reactor: &mut Reactor) -> Result<(), CoreError> {
1101 self.running
1102 .as_mut()
1103 .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1104 .register_with_reactor(reactor)
1105 }
1106
1107 /// Route one reactor event to the child's I/O drain state.
1108 pub fn handle_reactor_event(
1109 &mut self,
1110 reactor: &mut Reactor,
1111 event: &crate::fd::Event,
1112 ) -> Result<(), CoreError> {
1113 self.running
1114 .as_mut()
1115 .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1116 .handle_reactor_event(reactor, event)
1117 }
1118
1119 /// Return whether the stdout stream is paused on a full sink queue.
1120 pub fn stdout_paused(&self) -> bool {
1121 self.running
1122 .as_ref()
1123 .is_some_and(|running| running.stdout_paused())
1124 }
1125
1126 /// Return whether the stderr stream is paused on a full sink queue.
1127 pub fn stderr_paused(&self) -> bool {
1128 self.running
1129 .as_ref()
1130 .is_some_and(|running| running.stderr_paused())
1131 }
1132
1133 /// Re-deliver the held stdout chunk (if any) and re-register the fd when
1134 /// the sink has room again. Returns `true` when the stream is resumed.
1135 pub fn resume_stdout(&mut self, reactor: &mut Reactor) -> Result<bool, CoreError> {
1136 self.running
1137 .as_mut()
1138 .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1139 .resume_stdout(reactor)
1140 }
1141
1142 /// Re-deliver the held stderr chunk (if any) and re-register the fd when
1143 /// the sink has room again. Returns `true` when the stream is resumed.
1144 pub fn resume_stderr(&mut self, reactor: &mut Reactor) -> Result<bool, CoreError> {
1145 self.running
1146 .as_mut()
1147 .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
1148 .resume_stderr(reactor)
1149 }
1150
1151 /// Request cancellation using the daemon-owned policy from
1152 /// [`SpawnOptionsBuilder::cancel`]. Repeated requests are idempotent.
1153 pub fn request_cancel(&mut self) {
1154 self.cancel_at.get_or_insert_with(Instant::now);
1155 }
1156
1157 /// Earliest time at which [`Self::poll_completion`] should run again.
1158 ///
1159 /// A bounded reap tick is returned while the child is live, and exact
1160 /// timeout / TERM-to-KILL deadlines take precedence. `None` means the
1161 /// completion was already consumed.
1162 pub fn next_deadline(&self) -> Option<Instant> {
1163 self.running.as_ref()?;
1164 let now = Instant::now();
1165 let mut next = now + Duration::from_millis(100);
1166 if !self.timed_out
1167 && let Some(timeout_at) = self.timeout_at
1168 && timeout_at < next
1169 {
1170 next = timeout_at;
1171 }
1172 if self.kill_state == KillState::TermSent
1173 && let Some(cancel_at) = self.cancel_at
1174 {
1175 let kill_at = cancel_at + self.kill_grace;
1176 if kill_at < next {
1177 next = kill_at;
1178 }
1179 }
1180 // D-state bound: wake the caller once the post-SIGKILL reap window has
1181 // elapsed so `poll_completion` can give up on an unreapable child.
1182 if let Some(sent_at) = self.kill_sent_at {
1183 let bail_at = sent_at + D_STATE_REAP_BOUND;
1184 if bail_at < next {
1185 next = bail_at;
1186 }
1187 }
1188 Some(next)
1189 }
1190
1191 /// Advance timeout/cancellation, reap state, and completion.
1192 ///
1193 /// Returns `Ok(None)` while work remains, the normal [`Output`] once the
1194 /// child is reaped and its pipes are drained, or `EOVERFLOW` when the
1195 /// configured combined output limit was exceeded on the fully-drained
1196 /// path. A forced-close (timeout/cancel with a wedged pipe) returns the
1197 /// partial output and the `timed_out` flag instead, matching blocking
1198 /// [`spawn`].
1199 pub fn poll_completion(&mut self, reactor: &mut Reactor) -> Result<Option<Output>, CoreError> {
1200 let now = Instant::now();
1201 if !self.timed_out
1202 && let Some(timeout_at) = self.timeout_at
1203 && now >= timeout_at
1204 {
1205 self.timed_out = true;
1206 self.cancel_at.get_or_insert(timeout_at);
1207 if self.cancel == CancelPolicy::None {
1208 // `CancelPolicy::None` never signals, so the D-state bound
1209 // below never fires; record when the deadline passed so the
1210 // give-up bound mirrors blocking `spawn` (finding 14).
1211 self.deadline_passed_at = Some(self.deadline_passed_at.unwrap_or(now));
1212 }
1213 }
1214
1215 self.advance_cancel(now)?;
1216
1217 let running = self
1218 .running
1219 .as_ref()
1220 .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
1221 if self.status.is_none() {
1222 self.status = running.process.wait_step()?;
1223 }
1224
1225 let io_done = running.io_done();
1226 let paused = running.stdout_paused() || running.stderr_paused();
1227 // A paused stream (full sink queue, fd removed from the reactor) can
1228 // never make progress on its own: once the child is reaped, finish with
1229 // the partial output and the held pending chunk instead of waiting for
1230 // a readiness event that will never arrive.
1231 if self.status.is_some() && (io_done || self.cancel_at.is_some() || paused) {
1232 return self.finish(reactor, !io_done).map(Some);
1233 }
1234 // D-state: SIGKILL sent but the child still cannot be reaped. A child
1235 // stuck in uninterruptible sleep keeps the signal pending until it
1236 // leaves D-state; return the partial output instead of polling forever.
1237 if self.status.is_none()
1238 && self
1239 .kill_sent_at
1240 .is_some_and(|t| now.duration_since(t) >= D_STATE_REAP_BOUND)
1241 {
1242 return self.finish(reactor, true).map(Some);
1243 }
1244 // `CancelPolicy::None`: the deadline elapsed but nothing was ever
1245 // signaled, so a wedged child would poll forever. Give up with the
1246 // partial output after the same bound as the D-state path (finding 14).
1247 if self.status.is_none()
1248 && self.cancel == CancelPolicy::None
1249 && self
1250 .deadline_passed_at
1251 .is_some_and(|passed| now.duration_since(passed) >= D_STATE_REAP_BOUND)
1252 {
1253 return self.finish(reactor, true).map(Some);
1254 }
1255 Ok(None)
1256 }
1257
1258 fn advance_cancel(&mut self, now: Instant) -> Result<(), CoreError> {
1259 let Some(cancel_at) = self.cancel_at else {
1260 return Ok(());
1261 };
1262 // The child is already reaped — its pid may be recycled. Never signal.
1263 if self.status.is_some() {
1264 return Ok(());
1265 }
1266 let running = self
1267 .running
1268 .as_ref()
1269 .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
1270 let process = &running.process;
1271 let pid = process.pid();
1272 let pgid = effective_pgid(pid, self.pgroup);
1273 let target_is_group = self.pgroup.isolated || self.pgroup.leader.is_some();
1274 match self.kill_state {
1275 KillState::None => match self.cancel {
1276 CancelPolicy::None => {}
1277 CancelPolicy::Graceful => {
1278 let result = signal_process(process, target_is_group, pgid, libc::SIGTERM);
1279 self.kill_state = if result.is_ok() {
1280 KillState::TermSent
1281 } else {
1282 KillState::KillSent
1283 };
1284 if self.kill_state == KillState::KillSent {
1285 self.kill_sent_at = Some(now);
1286 }
1287 }
1288 CancelPolicy::Kill => {
1289 let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
1290 self.kill_state = KillState::KillSent;
1291 self.kill_sent_at = Some(now);
1292 }
1293 },
1294 KillState::TermSent if now >= cancel_at + self.kill_grace => {
1295 let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
1296 self.kill_state = KillState::KillSent;
1297 self.kill_sent_at = Some(now);
1298 }
1299 _ => {}
1300 }
1301 Ok(())
1302 }
1303
1304 fn finish(&mut self, reactor: &mut Reactor, force_close: bool) -> Result<Output, CoreError> {
1305 let mut running = self
1306 .running
1307 .take()
1308 .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
1309 for slot in running.drain.take_all_slots() {
1310 if slot.token.is_none() {
1311 continue;
1312 }
1313 if force_close {
1314 let _ = reactor.del(&slot.fd);
1315 } else {
1316 reactor.del(&slot.fd)?;
1317 }
1318 }
1319 let pid = running.process.pid();
1320 let stdout_pending = running.drain.take_stdout_pending();
1321 let stderr_pending = running.drain.take_stderr_pending();
1322 let (stdout, stderr, output_limit_exceeded, stdout_early_exited) =
1323 running.drain.into_parts_with_state();
1324 // If the child was never reaped (D-state give-up / forced close with an
1325 // unreapable child), it will eventually exit and become a zombie — hand
1326 // it to the reaper so it does not accumulate in a long-lived daemon
1327 // (finding 15).
1328 if self.status.is_none() {
1329 orphan_child(pid);
1330 }
1331 // Mirror blocking `spawn`: overflow is reported only when the drain
1332 // completed naturally. On the forced-close path (timeout/cancel with a
1333 // wedged pipe) the caller gets the partial output and the timed-out
1334 // flag instead, matching the blocking N4 behavior.
1335 if output_limit_exceeded && !force_close {
1336 return Err(CoreError::sys(libc::EOVERFLOW, "spawn output limit"));
1337 }
1338 Ok(Output {
1339 pid,
1340 status: self.status.take(),
1341 stdout,
1342 stderr,
1343 timed_out: self.timed_out,
1344 stdout_early_exited,
1345 stdout_pending,
1346 stderr_pending,
1347 })
1348 }
1349}
1350
1351impl Drop for ManagedProcess {
1352 fn drop(&mut self) {
1353 let Some(running) = self.running.take() else {
1354 return;
1355 };
1356 // If the child was already reaped by `poll_completion`, the pid may
1357 // have been recycled — never signal it. The pipes are dropped with
1358 // `running`, so there is nothing left to clean up.
1359 if self.status.is_some() {
1360 return;
1361 }
1362 let process = &running.process;
1363 let pid = process.pid();
1364 // Respect CancelPolicy::None: "do nothing on cancellation" must not
1365 // kill the child on Drop either — the caller asked that cancellation
1366 // leave the child alone.
1367 if self.cancel != CancelPolicy::None {
1368 let pgid = effective_pgid(pid, self.pgroup);
1369 let target_is_group = self.pgroup.isolated || self.pgroup.leader.is_some();
1370 let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
1371 }
1372 // Bound the reap wait: SIGKILL terminates a runnable child
1373 // immediately, but a child stuck in uninterruptible sleep (D-state)
1374 // never dies. Poll with WNOHANG so `Drop` cannot wedge the caller's
1375 // reactor thread forever on a stuck child.
1376 let deadline = Instant::now() + Duration::from_millis(100);
1377 while Instant::now() < deadline {
1378 match process.wait_step() {
1379 Ok(Some(_)) => return,
1380 Ok(None) => std::thread::sleep(Duration::from_millis(5)),
1381 Err(_) => return,
1382 }
1383 }
1384 // Give-up: the child is unreapable right now (D-state) or still
1385 // running under `CancelPolicy::None`. Nobody will `waitpid` it now;
1386 // hand it to the reaper so it does not become a zombie on exit.
1387 orphan_child(pid);
1388 }
1389}
1390
1391fn effective_pgid(pid: pid_t, pgroup: ProcessGroup) -> pid_t {
1392 match pgroup.leader {
1393 Some(0) | None => pid,
1394 Some(leader) => leader,
1395 }
1396}
1397
1398fn signal_process(
1399 process: &Process,
1400 target_is_group: bool,
1401 pgid: pid_t,
1402 signal: i32,
1403) -> Result<(), CoreError> {
1404 if target_is_group {
1405 process.kill_group(pgid, signal)
1406 } else {
1407 process.kill(signal)
1408 }
1409}
1410
1411/// Start spawning a process and return a monitor handle.
1412///
1413/// This initializes the pipes and starts the process, but does not block. Use
1414/// [`RunningProcess::register_with_reactor`],
1415/// [`RunningProcess::handle_reactor_event`], [`RunningProcess::io_done`], and
1416/// [`RunningProcess::into_output_parts`] to drive captured stdio without
1417/// exposing internal drain state.
1418///
1419/// ### Errors
1420/// - `EACCES`: Permission denied for the executable.
1421/// - `EINVAL`: Invalid spawn options (e.g. background capture without wait).
1422/// - `EMFILE`: Process limit on open file descriptors hit.
1423/// - `ENOENT`: The executable was not found.
1424/// - `ENOMEM`: Insufficient memory to spawn the process.
1425pub fn spawn_start(opts: SpawnOptions) -> Result<RunningProcess, CoreError> {
1426 if !opts.wait && (opts.stdin.is_some() || opts.capture_stdout || opts.capture_stderr) {
1427 return Err(CoreError::sys(
1428 libc::EINVAL,
1429 "background I/O capture not supported (wait must be true)",
1430 ));
1431 }
1432
1433 validate_backend(&opts)?;
1434
1435 let (process, drain) = match opts.backend {
1436 SpawnBackend::PosixSpawn => spawn_posix_internal(opts)?,
1437 SpawnBackend::Fork => spawn_fork_internal(opts)?,
1438 SpawnBackend::Vfork => spawn_vfork_internal(opts)?,
1439 SpawnBackend::Clone3 => spawn_clone3_internal(opts, false)?,
1440 SpawnBackend::Clone3Pidfd => spawn_clone3_internal(opts, true)?,
1441 };
1442
1443 Ok(RunningProcess { process, drain })
1444}
1445
1446/// Start a process whose complete lifecycle is driven by a caller-owned
1447/// reactor.
1448pub fn spawn_managed(opts: SpawnOptions) -> Result<ManagedProcess, CoreError> {
1449 if !opts.wait {
1450 return Err(CoreError::sys(
1451 libc::EINVAL,
1452 "managed process requires wait=true",
1453 ));
1454 }
1455 let timeout_at = opts
1456 .timeout_ms
1457 .map(|ms| Instant::now() + Duration::from_millis(ms as u64));
1458 let kill_grace = Duration::from_millis(opts.kill_grace_ms as u64);
1459 let cancel = opts.cancel;
1460 let pgroup = opts.pgroup;
1461 let running = spawn_start(opts)?;
1462 let pid = running.process.pid();
1463 Ok(ManagedProcess {
1464 running: Some(running),
1465 pid,
1466 timeout_at,
1467 kill_grace,
1468 cancel,
1469 pgroup,
1470 cancel_at: None,
1471 kill_state: KillState::None,
1472 status: None,
1473 timed_out: false,
1474 kill_sent_at: None,
1475 deadline_passed_at: None,
1476 })
1477}
1478
1479/// Spawn a process and block until completion or timeout.
1480///
1481/// This is the primary high-level interface for process execution. It handles
1482/// the full lifecycle, including I/O multiplexing and signal management.
1483///
1484/// ### Errors
1485/// Returns the same errors as [`spawn_start`], plus any I/O or reactor errors
1486/// encountered during the wait loop.
1487pub fn spawn(opts: SpawnOptions) -> Result<Output, CoreError> {
1488 let wait = opts.wait;
1489 let timeout_ms = opts.timeout_ms;
1490 let kill_grace_ms = opts.kill_grace_ms;
1491 let cancel = opts.cancel;
1492 let pgroup = opts.pgroup;
1493
1494 let mut reactor = Reactor::new()?;
1495 let running = spawn_start(opts)?;
1496
1497 let pid = running.process.pid();
1498 let mut drain = running.drain;
1499
1500 if let Err(e) = drain.register_with_reactor(&mut reactor) {
1501 // The child is live but stdio registration failed; `running` is
1502 // dropped here so nobody will `waitpid` it. Hand it to the reaper.
1503 orphan_child(pid);
1504 return Err(e);
1505 }
1506
1507 if !wait {
1508 let (stdout, stderr) = drain.into_parts();
1509 // The caller will never `wait` on this pid — hand it to the reaper so
1510 // it does not become a zombie when it exits (finding 15).
1511 orphan_child(pid);
1512 return Ok(Output {
1513 pid,
1514 status: None,
1515 stdout,
1516 stderr,
1517 timed_out: false,
1518 stdout_early_exited: false,
1519 stdout_pending: None,
1520 stderr_pending: None,
1521 });
1522 }
1523
1524 wait_loop(
1525 running.process,
1526 drain,
1527 reactor,
1528 timeout_ms,
1529 kill_grace_ms,
1530 cancel,
1531 pgroup,
1532 )
1533}
1534
1535#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1536enum KillState {
1537 None,
1538 TermSent,
1539 KillSent,
1540}
1541
1542fn wait_loop(
1543 process: Process,
1544 mut drain: crate::io::DrainState<fn(&[u8]) -> bool>,
1545 mut reactor: Reactor,
1546 timeout_ms: Option<u32>,
1547 kill_grace_ms: u32,
1548 cancel: CancelPolicy,
1549 pgroup: ProcessGroup,
1550) -> Result<Output, CoreError> {
1551 let pid = process.pid();
1552 // M8: the child's effective pgid is the configured leader when one is set
1553 // (Setpgid is applied after Setsid in the child), else its own pid. A
1554 // timeout must signal `-pgid`; `kill(-pid)` would target a different
1555 // group for a custom leader and the child would never die.
1556 let pgid = effective_pgid(pid, pgroup);
1557 let mut status_raw = process.wait_step()?;
1558 let mut state = KillState::None;
1559 let mut timed_out = false;
1560 // D-state bound: recorded once SIGKILL has been sent. If the child still
1561 // refuses to die (or be reaped) after `D_STATE_REAP_BOUND`, give up and
1562 // return the partial output instead of spinning on a stuck child.
1563 let mut kill_sent_at: Option<Instant> = None;
1564 // Deadline give-up bound for `CancelPolicy::None`: no signal is ever sent,
1565 // so `kill_sent_at` stays unset and the D-state bound never fires. A wedged
1566 // child (pipe held open by a descendant, child unreaped) would otherwise
1567 // poll at 100 ms forever. Once the deadline has passed we give up after the
1568 // same bound, returning the partial output with `timed_out` set.
1569 let mut deadline_passed_at: Option<Instant> = None;
1570
1571 let start_time = std::time::Instant::now();
1572 let deadline = timeout_ms.map(|t| std::time::Duration::from_millis(t as u64));
1573
1574 loop {
1575 let mut poll_timeout = -1;
1576
1577 if let Some(dl) = deadline {
1578 let elapsed = start_time.elapsed();
1579 if elapsed >= dl {
1580 timed_out = true;
1581 deadline_passed_at = Some(deadline_passed_at.unwrap_or_else(Instant::now));
1582 let elapsed_over = (elapsed - dl).as_millis();
1583
1584 let target_is_group = pgroup.isolated || pgroup.leader.is_some();
1585
1586 // Only signal while the child is unreaped. Once waitpid has
1587 // reaped it the pid may already be recycled by the OS — killing
1588 // it would hit an unrelated process. The wedged-pipe path below
1589 // returns the partial output without sending any signal.
1590 if status_raw.is_none() {
1591 match state {
1592 KillState::None => {
1593 if cancel == CancelPolicy::Graceful {
1594 let r = if target_is_group {
1595 process.kill_group(pgid, libc::SIGTERM)
1596 } else {
1597 process.kill(libc::SIGTERM)
1598 };
1599 if r.is_err() {
1600 state = KillState::KillSent; // Process already gone
1601 kill_sent_at = Some(Instant::now());
1602 } else {
1603 state = KillState::TermSent;
1604 }
1605 } else if cancel == CancelPolicy::Kill {
1606 let _ = if target_is_group {
1607 process.kill_group(pgid, libc::SIGKILL)
1608 } else {
1609 process.kill(libc::SIGKILL)
1610 };
1611 state = KillState::KillSent;
1612 kill_sent_at = Some(Instant::now());
1613 } else {
1614 // CancelPolicy::None just times out without killing
1615 }
1616 }
1617 KillState::TermSent if elapsed_over > kill_grace_ms as u128 => {
1618 let _ = if target_is_group {
1619 process.kill_group(pgid, libc::SIGKILL)
1620 } else {
1621 process.kill(libc::SIGKILL)
1622 };
1623 state = KillState::KillSent;
1624 kill_sent_at = Some(Instant::now());
1625 }
1626 _ => {}
1627 }
1628 }
1629 poll_timeout = 100; // Poll frequently while waiting for kill to take effect
1630 } else {
1631 let remaining = dl - elapsed;
1632 poll_timeout = remaining.as_millis().min(i32::MAX as u128) as i32;
1633 }
1634 }
1635
1636 if status_raw.is_none()
1637 && let Some(s) = process.wait_step()?
1638 {
1639 status_raw = Some(s);
1640 }
1641
1642 if drain.is_done() {
1643 let s = if status_raw.is_some() {
1644 status_raw.take()
1645 } else if deadline.is_none() {
1646 // C1: all pipes drained but the child is still alive, and no
1647 // deadline is set → block until it exits (intended semantics).
1648 Some(process.wait_blocking()?)
1649 } else {
1650 // C1: pipes drained with a deadline set → never block here; fall
1651 // through to the bounded `reactor.wait` below so the deadline
1652 // logic at the top of the loop kills and reaps. A later
1653 // `wait_step` reaps the child and we return from this branch.
1654 None
1655 };
1656
1657 if let Some(s) = s {
1658 for slot in drain.take_all_slots() {
1659 if slot.token.is_some() {
1660 reactor.del(&slot.fd)?;
1661 }
1662 }
1663 let stdout_pending = drain.take_stdout_pending();
1664 let stderr_pending = drain.take_stderr_pending();
1665 let (stdout, stderr, output_limit_exceeded, stdout_early_exited) =
1666 drain.into_parts_with_state();
1667 if output_limit_exceeded {
1668 return Err(CoreError::sys(libc::EOVERFLOW, "spawn output limit"));
1669 }
1670 return Ok(Output {
1671 pid,
1672 status: Some(s),
1673 stdout,
1674 stderr,
1675 timed_out,
1676 stdout_early_exited,
1677 stdout_pending,
1678 stderr_pending,
1679 });
1680 }
1681 }
1682
1683 // Streaming mode: a paused stream (full sink queue) cannot progress
1684 // even after the child is reaped — the fd is not registered, so no
1685 // readiness event will ever arrive. Return the partial output and the
1686 // held pending chunk for the caller to flush (the blocking-path mirror
1687 // of `poll_completion`'s paused-finish branch).
1688 if status_raw.is_some() && (drain.stdout_paused() || drain.stderr_paused()) {
1689 for slot in drain.take_all_slots() {
1690 if slot.token.is_some() {
1691 let _ = reactor.del(&slot.fd);
1692 }
1693 }
1694 let stdout_pending = drain.take_stdout_pending();
1695 let stderr_pending = drain.take_stderr_pending();
1696 let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
1697 drain.into_parts_with_state();
1698 return Ok(Output {
1699 pid,
1700 status: status_raw,
1701 stdout,
1702 stderr,
1703 timed_out,
1704 stdout_early_exited,
1705 stdout_pending,
1706 stderr_pending,
1707 });
1708 }
1709
1710 // N4: the deadline has elapsed and the child is reaped, but a wedged
1711 // pipe (a descendant inheriting the write end) keeps the drain from
1712 // closing. The absolute deadline is authoritative — return the partial
1713 // output instead of spinning forever.
1714 if timed_out && status_raw.is_some() {
1715 for slot in drain.take_all_slots() {
1716 if slot.token.is_some() {
1717 let _ = reactor.del(&slot.fd);
1718 }
1719 }
1720 let stdout_pending = drain.take_stdout_pending();
1721 let stderr_pending = drain.take_stderr_pending();
1722 let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
1723 drain.into_parts_with_state();
1724 return Ok(Output {
1725 pid,
1726 status: status_raw,
1727 stdout,
1728 stderr,
1729 timed_out: true,
1730 stdout_early_exited,
1731 stdout_pending,
1732 stderr_pending,
1733 });
1734 }
1735
1736 // D-state: SIGKILL has been sent but the child is still unreaped after
1737 // the bound. A child stuck in uninterruptible sleep keeps the signal
1738 // pending until it leaves D-state, so no further wait can succeed —
1739 // return the partial output rather than polling forever. The pid is
1740 // not signaled again (it may be recycled once it finally exits).
1741 if let Some(sent_at) = kill_sent_at
1742 && sent_at.elapsed() >= D_STATE_REAP_BOUND
1743 && status_raw.is_none()
1744 {
1745 for slot in drain.take_all_slots() {
1746 if slot.token.is_some() {
1747 let _ = reactor.del(&slot.fd);
1748 }
1749 }
1750 // The child is unreapable right now but will eventually leave
1751 // D-state and exit; nobody will wait on it after this give-up, so
1752 // hand it to the reaper (finding 15).
1753 orphan_child(pid);
1754 let stdout_pending = drain.take_stdout_pending();
1755 let stderr_pending = drain.take_stderr_pending();
1756 let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
1757 drain.into_parts_with_state();
1758 return Ok(Output {
1759 pid,
1760 status: None,
1761 stdout,
1762 stderr,
1763 timed_out: true,
1764 stdout_early_exited,
1765 stdout_pending,
1766 stderr_pending,
1767 });
1768 }
1769
1770 // `CancelPolicy::None`: the deadline elapsed but nothing was ever
1771 // signaled, so the child may stay wedged (pipe held by a descendant,
1772 // child unreaped) indefinitely. Give up with the partial output after
1773 // the same bound as the D-state path — otherwise this polls at 100 ms
1774 // forever (finding 14).
1775 if cancel == CancelPolicy::None
1776 && timed_out
1777 && status_raw.is_none()
1778 && deadline_passed_at.is_some_and(|passed| passed.elapsed() >= D_STATE_REAP_BOUND)
1779 {
1780 for slot in drain.take_all_slots() {
1781 if slot.token.is_some() {
1782 let _ = reactor.del(&slot.fd);
1783 }
1784 }
1785 // The child was never signaled and may still be running; nobody
1786 // will wait on it now — hand it to the reaper (finding 15).
1787 orphan_child(pid);
1788 let stdout_pending = drain.take_stdout_pending();
1789 let stderr_pending = drain.take_stderr_pending();
1790 let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
1791 drain.into_parts_with_state();
1792 return Ok(Output {
1793 pid,
1794 status: None,
1795 stdout,
1796 stderr,
1797 timed_out: true,
1798 stdout_early_exited,
1799 stdout_pending,
1800 stderr_pending,
1801 });
1802 }
1803
1804 // Streaming backpressure: while a stream is paused its fd is not
1805 // registered (no readiness events). Keep trying to resume so a
1806 // concurrent queue consumer's drained capacity re-registers the fd,
1807 // and bound the poll so the loop cannot block forever on a paused
1808 // stream.
1809 if drain.stdout_paused() || drain.stderr_paused() {
1810 if drain.stdout_paused() {
1811 let _ = drain.resume_stdout(&mut reactor);
1812 }
1813 if drain.stderr_paused() {
1814 let _ = drain.resume_stderr(&mut reactor);
1815 }
1816 if !(0..=10).contains(&poll_timeout) {
1817 poll_timeout = 10;
1818 }
1819 }
1820
1821 let timeout = poll_timeout;
1822
1823 let mut events = Vec::new();
1824 let nevents = reactor.wait(&mut events, 64, timeout)?;
1825
1826 for ev in events.iter().take(nevents) {
1827 if drain.stdout_matches(ev.token) {
1828 if ev.readable || ev.hangup {
1829 drain.handle_stdout_ready(&mut reactor)?;
1830 } else if ev.error {
1831 drain.drop_stdout(&mut reactor)?;
1832 }
1833 } else if drain.stderr_matches(ev.token) {
1834 if ev.readable || ev.hangup {
1835 drain.handle_stderr_ready(&mut reactor)?;
1836 } else if ev.error {
1837 drain.drop_stderr(&mut reactor)?;
1838 }
1839 } else if drain.stdin_matches(ev.token) {
1840 if ev.writable {
1841 drain.handle_stdin_writable(&mut reactor)?;
1842 } else if ev.error || ev.hangup {
1843 drain.drop_stdin(&mut reactor)?;
1844 }
1845 }
1846 }
1847 }
1848}