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