Skip to main content

coreshift_core/spawn/
mod.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/
4
5//! Process spawning and lifecycle management.
6//!
7//! This module exposes explicit Linux/Android process primitives. Callers must
8//! provide the exact argument vector and choose the spawn backend. Core does not
9//! infer shell/root behavior, select backends from platform properties, or
10//! silently switch between backends.
11
12use std::os::unix::io::RawFd;
13use std::time::{Duration, Instant};
14
15use crate::CoreError;
16use crate::error::syscall_ret;
17use crate::fd::Fd;
18use crate::io::DrainState;
19use crate::reactor::Reactor;
20use libc::{O_CLOEXEC, WEXITSTATUS, WIFEXITED, WIFSIGNALED, WTERMSIG, pid_t, pipe2, waitpid};
21
22mod exec;
23mod fork;
24mod posix;
25
26use exec::ExecContext;
27use fork::spawn_fork_internal;
28use posix::spawn_posix_internal;
29
30unsafe extern "C" {
31    pub(crate) static mut environ: *mut *mut libc::c_char;
32}
33
34/// Upper bound on how long to keep polling for a reap after SIGKILL has been
35/// sent. A child stuck in uninterruptible sleep (D-state) cannot be reaped at
36/// all — SIGKILL stays pending until it leaves D-state — so after this window
37/// the wait loop gives up and returns the partial output instead of spinning
38/// forever. Mirrors the bounded reap wait in [`ManagedProcess`]'s `Drop`.
39const D_STATE_REAP_BOUND: Duration = Duration::from_millis(500);
40
41/// Policy for handling process cancellation or timeouts.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum CancelPolicy {
44    /// Do nothing on cancellation; let the process run to completion.
45    #[default]
46    None,
47    /// Send SIGTERM, then SIGKILL after a grace period.
48    Graceful,
49    /// Send SIGKILL immediately.
50    Kill,
51}
52
53/// Process group and session configuration.
54#[derive(Debug, Clone, Copy, Default)]
55pub struct ProcessGroup {
56    /// Join an existing process group leader.
57    pub leader: Option<pid_t>,
58    /// Create a new session (`setsid`).
59    pub isolated: bool,
60}
61
62impl ProcessGroup {
63    /// Create a new process group configuration.
64    pub fn new(leader: Option<pid_t>, isolated: bool) -> Self {
65        Self { leader, isolated }
66    }
67}
68
69#[inline(always)]
70fn errno() -> i32 {
71    std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
72}
73
74/// Relocate `fd` to the lowest available descriptor `>= 3`, closing the
75/// original. Guards against `pipe2` handing back fds 0/1/2 when the daemon
76/// runs with stdio closed: a pipe on 0/1/2 would collide with the child's
77/// `dup2(…, 0/1/2)` setup (clobbering a still-needed end) and with the
78/// stdio-tracking in `close_child_fds_for_policy`.
79fn relocate_above_stdio(fd: RawFd, op: &'static str) -> Result<RawFd, CoreError> {
80    if fd >= 3 {
81        return Ok(fd);
82    }
83    let new = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 3) };
84    syscall_ret(new, op)?;
85    unsafe {
86        libc::close(fd);
87    }
88    Ok(new)
89}
90
91/// Creates a pipe with O_CLOEXEC, relocated above stdio. Both ends stay
92/// blocking; the parent-facing ends are flipped to O_NONBLOCK by
93/// [`DrainState`] after spawn so the child never inherits a non-blocking
94/// stdio (which would silently truncate child output on `EAGAIN`).
95/// Invariants: FDs returned are strictly >= 3 and will close automatically on drop.
96#[inline(always)]
97fn make_pipe() -> Result<(Fd, Fd), CoreError> {
98    let mut fds = [0; 2];
99    let r = unsafe { pipe2(fds.as_mut_ptr(), O_CLOEXEC) };
100    syscall_ret(r, "pipe2")?;
101    let r0 = match relocate_above_stdio(fds[0], "pipe2:relocate") {
102        Ok(fd) => fd,
103        Err(e) => {
104            // fds[0] is still open when its relocation fails; close to avoid
105            // leaking under fd pressure (EMFILE).
106            unsafe {
107                libc::close(fds[0]);
108            }
109            return Err(e);
110        }
111    };
112    let r1 = match relocate_above_stdio(fds[1], "pipe2:relocate") {
113        Ok(fd) => fd,
114        Err(e) => {
115            // fds[1] is still open (relocation failed), and r0 was relocated
116            // above — both would leak on this error path.
117            unsafe {
118                libc::close(r0);
119                libc::close(fds[1]);
120            }
121            return Err(e);
122        }
123    };
124    Ok((Fd::new(r0, "pipe2")?, Fd::new(r1, "pipe2")?))
125}
126
127fn make_cloexec_pipe() -> Result<(RawFd, RawFd), CoreError> {
128    let mut fds = [0; 2];
129    let r = unsafe { pipe2(fds.as_mut_ptr(), O_CLOEXEC) };
130    syscall_ret(r, "pipe2")?;
131    let r0 = match relocate_above_stdio(fds[0], "pipe2:relocate") {
132        Ok(fd) => fd,
133        Err(e) => {
134            unsafe {
135                libc::close(fds[0]);
136            }
137            return Err(e);
138        }
139    };
140    let r1 = match relocate_above_stdio(fds[1], "pipe2:relocate") {
141        Ok(fd) => fd,
142        Err(e) => {
143            unsafe {
144                libc::close(r0);
145                libc::close(fds[1]);
146            }
147            return Err(e);
148        }
149    };
150    Ok((r0, r1))
151}
152
153struct Pipes {
154    stdin_r: Option<Fd>,
155    stdin_w: Option<Fd>,
156    stdout_r: Option<Fd>,
157    stdout_w: Option<Fd>,
158    stderr_r: Option<Fd>,
159    stderr_w: Option<Fd>,
160}
161
162impl Pipes {
163    fn new(in_buf: Option<&[u8]>, out: bool, err: bool) -> Result<Self, CoreError> {
164        let (stdin_r, stdin_w) = if in_buf.is_some() {
165            let (r, w) = make_pipe()?;
166            (Some(r), Some(w))
167        } else {
168            (None, None)
169        };
170
171        let (stdout_r, stdout_w) = if out {
172            let (r, w) = make_pipe()?;
173            (Some(r), Some(w))
174        } else {
175            (None, None)
176        };
177
178        let (stderr_r, stderr_w) = if err {
179            let (r, w) = make_pipe()?;
180            (Some(r), Some(w))
181        } else {
182            (None, None)
183        };
184
185        Ok(Self {
186            stdin_r,
187            stdin_w,
188            stdout_r,
189            stdout_w,
190            stderr_r,
191            stderr_w,
192        })
193    }
194
195    #[inline(always)]
196    fn close_all(&mut self) {
197        self.stdin_r.take();
198        self.stdin_w.take();
199        self.stdout_r.take();
200        self.stdout_w.take();
201        self.stderr_r.take();
202        self.stderr_w.take();
203    }
204}
205
206/// Represents the termination status of a process.
207#[derive(Debug, PartialEq, Eq)]
208pub enum ExitStatus {
209    /// Process exited normally with the specified code.
210    Exited(i32),
211    /// Process was terminated by a signal.
212    Signaled(i32),
213}
214
215/// Explicit process spawning backend.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum SpawnBackend {
218    /// Force the use of `posix_spawn`.
219    PosixSpawn,
220    /// Force the use of `fork`/`exec`.
221    ///
222    /// The fork backend supports explicit [`SpawnFdPolicy`] handling before
223    /// `execve`.
224    Fork,
225}
226
227/// Explicit file-descriptor inheritance policy for spawned children.
228#[derive(Debug, Clone, PartialEq, Eq, Default)]
229pub enum SpawnFdPolicy {
230    /// Inherit descriptors according to their existing `FD_CLOEXEC` flags.
231    #[default]
232    CloexecOnly,
233    /// For the fork backend, close every descriptor >= 3 before `execve`,
234    /// except Core-required pipe descriptors.
235    CloseFrom3,
236    /// For the fork backend, close every descriptor >= 3 before `execve`,
237    /// except Core-required pipe descriptors and the listed descriptors.
238    ///
239    /// Core does not close allowlisted descriptors, but their existing
240    /// `FD_CLOEXEC` state still applies. Callers that want an allowlisted
241    /// descriptor to survive `execve` must clear `FD_CLOEXEC` before spawning.
242    Allowlist(Vec<RawFd>),
243}
244
245#[inline(always)]
246fn decode_status(status: i32) -> ExitStatus {
247    if WIFEXITED(status) {
248        ExitStatus::Exited(WEXITSTATUS(status))
249    } else if WIFSIGNALED(status) {
250        ExitStatus::Signaled(WTERMSIG(status))
251    } else {
252        ExitStatus::Exited(-1)
253    }
254}
255
256/// A handle to a spawned process.
257///
258/// ### Fork Safety
259/// The process handle contains a PID. After a `fork`, the child process will
260/// have a copy of this PID, but it refers to the same original process.
261/// Calling `wait` or `kill` from the child may lead to confusing results
262/// if multiple processes are managing the same PID.
263pub struct Process {
264    pid: pid_t,
265}
266
267impl Process {
268    /// Create a handle for an existing PID.
269    pub fn new(pid: pid_t) -> Self {
270        Self { pid }
271    }
272
273    /// Return the process ID.
274    pub fn pid(&self) -> pid_t {
275        self.pid
276    }
277
278    /// Perform a non-blocking wait for process termination.
279    ///
280    /// ### Errors
281    /// - `ECHILD`: The process does not exist or is not a child of the caller.
282    /// - `EINTR`: The call was interrupted by a signal (handled internally).
283    pub fn wait_step(&self) -> Result<Option<ExitStatus>, CoreError> {
284        loop {
285            let mut status = 0;
286            let r = unsafe { waitpid(self.pid, &mut status, libc::WNOHANG) };
287            if r == 0 {
288                return Ok(None);
289            }
290            if r < 0 {
291                let e = errno();
292                if e == libc::EINTR {
293                    continue;
294                }
295                return Err(CoreError::sys(e, "waitpid_step"));
296            }
297            return Ok(Some(decode_status(status)));
298        }
299    }
300
301    /// Block until the process terminates.
302    ///
303    /// ### Errors
304    /// - `ECHILD`: The process does not exist or is not a child of the caller.
305    pub fn wait_blocking(&self) -> Result<ExitStatus, CoreError> {
306        loop {
307            let mut status = 0;
308            let r = unsafe { waitpid(self.pid, &mut status, 0) };
309            if r < 0 {
310                let e = errno();
311                if e == libc::EINTR {
312                    continue;
313                }
314                return Err(CoreError::sys(e, "waitpid_blocking"));
315            }
316            return Ok(decode_status(status));
317        }
318    }
319
320    /// Send a signal to the process.
321    ///
322    /// ### Errors
323    /// - `EINVAL`: Invalid signal number, or a non-positive pid (pid `0`
324    ///   would signal the caller's own process group).
325    /// - `EPERM`: The caller does not have permission to send the signal.
326    /// - `ESRCH`: The process does not exist.
327    pub fn kill(&self, sig: i32) -> Result<(), CoreError> {
328        if self.pid <= 0 {
329            return Err(CoreError::sys(libc::EINVAL, "kill: invalid pid"));
330        }
331        let r = unsafe { libc::kill(self.pid, sig) };
332        if r < 0 {
333            let e = errno();
334            if e == libc::ESRCH {
335                return Ok(());
336            }
337            syscall_ret(-1, "kill")?;
338        }
339        Ok(())
340    }
341
342    /// Signal the process group whose id equals [`Self::pid`] — valid only
343    /// when the process is its own group/session leader. For a child placed
344    /// into a custom leader's group use [`Self::kill_group`].
345    ///
346    /// ### Errors
347    /// Same as [`Self::kill`].
348    pub fn kill_pgroup(&self, sig: i32) -> Result<(), CoreError> {
349        self.kill_group(self.pid, sig)
350    }
351
352    /// Send a signal to an explicit process group.
353    ///
354    /// The pgid must be the child's actual group (its own pid after `setsid`,
355    /// or the configured leader's id after `setpgid`), never guessed from the
356    /// pid, and never `0` or negative — `kill(-0)` would signal the caller's
357    /// own process group.
358    ///
359    /// ### Errors
360    /// Same as [`Self::kill`], plus `EINVAL` for a non-positive pgid.
361    pub fn kill_group(&self, pgid: pid_t, sig: i32) -> Result<(), CoreError> {
362        if pgid <= 0 {
363            return Err(CoreError::sys(libc::EINVAL, "kill_group: invalid pgid"));
364        }
365        let r = unsafe { libc::kill(-pgid, sig) };
366        if r < 0 {
367            let e = errno();
368            if e == libc::ESRCH {
369                return Ok(());
370            }
371            syscall_ret(-1, "kill_group")?;
372        }
373        Ok(())
374    }
375}
376
377/// Configuration options for spawning a new process.
378#[derive(Clone)]
379pub struct SpawnOptions {
380    ctx: ExecContext,
381    stdin: Option<Box<[u8]>>,
382    capture_stdout: bool,
383    capture_stderr: bool,
384    wait: bool,
385    pgroup: ProcessGroup,
386    max_output: usize,
387    timeout_ms: Option<u32>,
388    kill_grace_ms: u32,
389    cancel: CancelPolicy,
390    backend: SpawnBackend,
391    fd_policy: SpawnFdPolicy,
392    early_exit: Option<fn(&[u8]) -> bool>,
393}
394
395impl SpawnOptions {
396    /// Create a new builder for process spawning.
397    pub fn builder(argv: Vec<String>, backend: SpawnBackend) -> SpawnOptionsBuilder {
398        SpawnOptionsBuilder::new(argv, backend)
399    }
400
401    /// Execute the process according to the options and block until completion.
402    pub fn run(self) -> Result<Output, CoreError> {
403        spawn(self)
404    }
405}
406
407/// Builder for [`SpawnOptions`].
408#[derive(Clone)]
409pub struct SpawnOptionsBuilder {
410    argv: Vec<String>,
411    env: Option<Vec<String>>,
412    cwd: Option<String>,
413    stdin: Option<Box<[u8]>>,
414    capture_stdout: bool,
415    capture_stderr: bool,
416    wait: bool,
417    pgroup: ProcessGroup,
418    max_output: usize,
419    timeout_ms: Option<u32>,
420    kill_grace_ms: u32,
421    cancel: CancelPolicy,
422    backend: SpawnBackend,
423    fd_policy: SpawnFdPolicy,
424    early_exit: Option<fn(&[u8]) -> bool>,
425}
426
427impl SpawnOptionsBuilder {
428    /// Create a new builder with the specified argument vector.
429    pub fn new(argv: Vec<String>, backend: SpawnBackend) -> Self {
430        Self {
431            argv,
432            env: None,
433            cwd: None,
434            stdin: None,
435            capture_stdout: false,
436            capture_stderr: false,
437            wait: true,
438            pgroup: ProcessGroup::default(),
439            max_output: 1024 * 1024,
440            timeout_ms: None,
441            kill_grace_ms: 2000,
442            cancel: CancelPolicy::Kill,
443            backend,
444            fd_policy: SpawnFdPolicy::default(),
445            early_exit: None,
446        }
447    }
448
449    /// Set environment variables.
450    pub fn env(mut self, env: Vec<String>) -> Self {
451        self.env = Some(env);
452        self
453    }
454
455    /// Set the working directory.
456    pub fn cwd(mut self, cwd: String) -> Self {
457        self.cwd = Some(cwd);
458        self
459    }
460
461    /// Provide data to be written to the child's stdin.
462    pub fn stdin(mut self, data: impl Into<Box<[u8]>>) -> Self {
463        self.stdin = Some(data.into());
464        self
465    }
466
467    /// Enable stdout capture.
468    pub fn capture_stdout(mut self) -> Self {
469        self.capture_stdout = true;
470        self
471    }
472
473    /// Enable stderr capture.
474    pub fn capture_stderr(mut self) -> Self {
475        self.capture_stderr = true;
476        self
477    }
478
479    /// Set whether to wait for the process to terminate (default: true).
480    pub fn wait(mut self, wait: bool) -> Self {
481        self.wait = wait;
482        self
483    }
484
485    /// Set process group and isolation policy.
486    pub fn pgroup(mut self, pgroup: ProcessGroup) -> Self {
487        self.pgroup = pgroup;
488        self
489    }
490
491    /// Set the combined stdout+stderr output buffer size (default: 1MB).
492    ///
493    /// If captured output exceeds this limit, spawn drains the child pipes to
494    /// completion and returns `EOVERFLOW`.
495    pub fn max_output(mut self, max: usize) -> Self {
496        self.max_output = max;
497        self
498    }
499
500    /// Set the execution timeout in milliseconds.
501    pub fn timeout_ms(mut self, ms: u32) -> Self {
502        self.timeout_ms = Some(ms);
503        self
504    }
505
506    /// Set the grace period before SIGKILL (default: 2s).
507    pub fn kill_grace_ms(mut self, ms: u32) -> Self {
508        self.kill_grace_ms = ms;
509        self
510    }
511
512    /// Set the cancellation policy (default: Kill).
513    pub fn cancel(mut self, policy: CancelPolicy) -> Self {
514        self.cancel = policy;
515        self
516    }
517
518    /// Set the child file-descriptor inheritance policy.
519    pub fn fd_policy(mut self, policy: SpawnFdPolicy) -> Self {
520        self.fd_policy = policy;
521        self
522    }
523
524    /// Set an early exit callback.
525    pub fn early_exit(mut self, callback: fn(&[u8]) -> bool) -> Self {
526        self.early_exit = Some(callback);
527        self
528    }
529
530    /// Build the spawn options.
531    pub fn build(self) -> Result<SpawnOptions, CoreError> {
532        let ctx = ExecContext::new(self.argv, self.env, self.cwd)?;
533        Ok(SpawnOptions {
534            ctx,
535            stdin: self.stdin,
536            capture_stdout: self.capture_stdout,
537            capture_stderr: self.capture_stderr,
538            wait: self.wait,
539            pgroup: self.pgroup,
540            max_output: self.max_output,
541            timeout_ms: self.timeout_ms,
542            kill_grace_ms: self.kill_grace_ms,
543            cancel: self.cancel,
544            backend: self.backend,
545            fd_policy: self.fd_policy,
546            early_exit: self.early_exit,
547        })
548    }
549}
550
551/// The result of a process execution.
552#[derive(Debug)]
553pub struct Output {
554    /// The PID of the finished process.
555    pub pid: pid_t,
556    /// Final exit status (None if `wait=false`).
557    pub status: Option<ExitStatus>,
558    /// Captured stdout buffer.
559    pub stdout: Vec<u8>,
560    /// Captured stderr buffer.
561    pub stderr: Vec<u8>,
562    /// Whether the process timed out.
563    pub timed_out: bool,
564    /// Whether stdout drain stopped because the early-exit callback matched.
565    pub stdout_early_exited: bool,
566}
567
568fn validate_backend(opts: &SpawnOptions) -> Result<(), CoreError> {
569    validate_fd_policy(&opts.fd_policy)?;
570    match opts.backend {
571        SpawnBackend::PosixSpawn => {
572            if opts.ctx.cwd.is_some() {
573                return Err(CoreError::sys(libc::EINVAL, "posix_spawn cwd unsupported"));
574            }
575            if opts.pgroup.isolated {
576                return Err(CoreError::sys(
577                    libc::EINVAL,
578                    "posix_spawn setsid unsupported",
579                ));
580            }
581            if opts.fd_policy != SpawnFdPolicy::CloexecOnly {
582                return Err(CoreError::sys(
583                    libc::EINVAL,
584                    "posix_spawn fd policy unsupported",
585                ));
586            }
587            Ok(())
588        }
589        SpawnBackend::Fork => {
590            // After `setsid` the child is a session leader in a brand-new
591            // session; `setpgid(0, leader)` for a leader outside that session
592            // always fails with EPERM. A zero leader means "own pid" (the
593            // child's own group after setsid), which is valid.
594            if opts.pgroup.isolated && opts.pgroup.leader.is_some_and(|l| l != 0) {
595                return Err(CoreError::sys(
596                    libc::EINVAL,
597                    "fork isolated + custom setpgid leader unsupported",
598                ));
599            }
600            Ok(())
601        }
602    }
603}
604
605fn validate_fd_policy(policy: &SpawnFdPolicy) -> Result<(), CoreError> {
606    if let SpawnFdPolicy::Allowlist(fds) = policy {
607        let mut seen = Vec::with_capacity(fds.len());
608        for &fd in fds {
609            if fd < 0 {
610                return Err(CoreError::sys(libc::EINVAL, "spawn fd allowlist invalid"));
611            }
612            let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
613            if flags < 0 {
614                return Err(CoreError::sys(errno(), "spawn fd allowlist fcntl(F_GETFD)"));
615            }
616            if seen.contains(&fd) {
617                return Err(CoreError::sys(libc::EINVAL, "spawn fd allowlist duplicate"));
618            }
619            seen.push(fd);
620        }
621    }
622    Ok(())
623}
624
625/// Specialized drain state for process spawning.
626pub type SpawnDrain = DrainState<fn(&[u8]) -> bool>;
627
628/// A process that is currently running and being monitored.
629///
630/// ### Fork Safety
631/// This handle contains both a PID and owned file descriptors for process I/O.
632/// Upon `fork`, the descriptors are inherited. Standard `O_CLOEXEC` behavior
633/// applies after `exec`.
634pub struct RunningProcess {
635    /// Handle to the process.
636    pub process: Process,
637    drain: SpawnDrain,
638}
639
640/// Full process lifecycle driven by a caller-owned reactor.
641///
642/// `ManagedProcess` preserves the blocking [`spawn`] semantics while allowing
643/// an application reactor to stay responsive: Core owns timeout/cancellation
644/// escalation, process-group signaling, pipe draining, overflow reporting, and
645/// `waitpid` reaping; the caller only routes readiness events and polls on
646/// [`Self::next_deadline`].
647pub struct ManagedProcess {
648    running: Option<RunningProcess>,
649    pid: pid_t,
650    timeout_at: Option<Instant>,
651    kill_grace: Duration,
652    cancel: CancelPolicy,
653    pgroup: ProcessGroup,
654    cancel_at: Option<Instant>,
655    kill_state: KillState,
656    status: Option<ExitStatus>,
657    timed_out: bool,
658    kill_sent_at: Option<Instant>,
659}
660
661impl RunningProcess {
662    /// Register active stdio pipe descriptors with a reactor.
663    ///
664    /// Call this once after [`spawn_start`] when the process was started with
665    /// captured output or stdin data. The assigned tokens are kept internally
666    /// and later matched by [`Self::handle_reactor_event`].
667    pub fn register_with_reactor(&mut self, reactor: &mut Reactor) -> Result<(), CoreError> {
668        self.drain.register_with_reactor(reactor)
669    }
670
671    /// Apply one reactor readiness event to this process' stdio drain state.
672    ///
673    /// Events for unrelated tokens are ignored. Callers remain responsible for
674    /// waiting on [`Self::process`] and driving the reactor until [`Self::io_done`]
675    /// returns true.
676    pub fn handle_reactor_event(
677        &mut self,
678        reactor: &mut Reactor,
679        event: &crate::fd::Event,
680    ) -> Result<(), CoreError> {
681        if self.drain.stdout_matches(event.token) {
682            if event.readable || event.hangup {
683                self.drain.handle_stdout_ready(reactor)?;
684            } else if event.error {
685                self.drain.drop_stdout(reactor)?;
686            }
687        } else if self.drain.stderr_matches(event.token) {
688            if event.readable || event.hangup {
689                self.drain.handle_stderr_ready(reactor)?;
690            } else if event.error {
691                self.drain.drop_stderr(reactor)?;
692            }
693        } else if self.drain.stdin_matches(event.token) {
694            if event.writable {
695                self.drain.handle_stdin_writable(reactor)?;
696            } else if event.error || event.hangup {
697                self.drain.drop_stdin(reactor)?;
698            }
699        }
700        Ok(())
701    }
702
703    /// Return whether all managed stdio pipes have been drained or closed.
704    pub fn io_done(&self) -> bool {
705        self.drain.is_done()
706    }
707
708    /// Consume the running process handle and return captured stdout/stderr buffers.
709    pub fn into_output_parts(self) -> (Vec<u8>, Vec<u8>) {
710        self.drain.into_parts()
711    }
712}
713
714impl ManagedProcess {
715    /// Return the child PID.
716    ///
717    /// The PID is captured at spawn time, so this remains available after the
718    /// process has completed (unlike the running handle, which is consumed).
719    pub fn pid(&self) -> pid_t {
720        self.pid
721    }
722
723    /// Register active child I/O descriptors with the caller's reactor.
724    pub fn register_with_reactor(&mut self, reactor: &mut Reactor) -> Result<(), CoreError> {
725        self.running
726            .as_mut()
727            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
728            .register_with_reactor(reactor)
729    }
730
731    /// Route one reactor event to the child's I/O drain state.
732    pub fn handle_reactor_event(
733        &mut self,
734        reactor: &mut Reactor,
735        event: &crate::fd::Event,
736    ) -> Result<(), CoreError> {
737        self.running
738            .as_mut()
739            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
740            .handle_reactor_event(reactor, event)
741    }
742
743    /// Request cancellation using the daemon-owned policy from
744    /// [`SpawnOptionsBuilder::cancel`]. Repeated requests are idempotent.
745    pub fn request_cancel(&mut self) {
746        self.cancel_at.get_or_insert_with(Instant::now);
747    }
748
749    /// Earliest time at which [`Self::poll_completion`] should run again.
750    ///
751    /// A bounded reap tick is returned while the child is live, and exact
752    /// timeout / TERM-to-KILL deadlines take precedence. `None` means the
753    /// completion was already consumed.
754    pub fn next_deadline(&self) -> Option<Instant> {
755        self.running.as_ref()?;
756        let now = Instant::now();
757        let mut next = now + Duration::from_millis(100);
758        if !self.timed_out
759            && let Some(timeout_at) = self.timeout_at
760            && timeout_at < next
761        {
762            next = timeout_at;
763        }
764        if self.kill_state == KillState::TermSent
765            && let Some(cancel_at) = self.cancel_at
766        {
767            let kill_at = cancel_at + self.kill_grace;
768            if kill_at < next {
769                next = kill_at;
770            }
771        }
772        // D-state bound: wake the caller once the post-SIGKILL reap window has
773        // elapsed so `poll_completion` can give up on an unreapable child.
774        if let Some(sent_at) = self.kill_sent_at {
775            let bail_at = sent_at + D_STATE_REAP_BOUND;
776            if bail_at < next {
777                next = bail_at;
778            }
779        }
780        Some(next)
781    }
782
783    /// Advance timeout/cancellation, reap state, and completion.
784    ///
785    /// Returns `Ok(None)` while work remains, the normal [`Output`] once the
786    /// child is reaped and its pipes are drained, or `EOVERFLOW` when the
787    /// configured combined output limit was exceeded on the fully-drained
788    /// path. A forced-close (timeout/cancel with a wedged pipe) returns the
789    /// partial output and the `timed_out` flag instead, matching blocking
790    /// [`spawn`].
791    pub fn poll_completion(&mut self, reactor: &mut Reactor) -> Result<Option<Output>, CoreError> {
792        let now = Instant::now();
793        if !self.timed_out
794            && let Some(timeout_at) = self.timeout_at
795            && now >= timeout_at
796        {
797            self.timed_out = true;
798            self.cancel_at.get_or_insert(timeout_at);
799        }
800
801        self.advance_cancel(now)?;
802
803        let running = self
804            .running
805            .as_ref()
806            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
807        if self.status.is_none() {
808            self.status = running.process.wait_step()?;
809        }
810
811        let io_done = running.io_done();
812        if self.status.is_some() && (io_done || self.cancel_at.is_some()) {
813            return self.finish(reactor, !io_done).map(Some);
814        }
815        // D-state: SIGKILL sent but the child still cannot be reaped. A child
816        // stuck in uninterruptible sleep keeps the signal pending until it
817        // leaves D-state; return the partial output instead of polling forever.
818        if self.status.is_none()
819            && self
820                .kill_sent_at
821                .is_some_and(|t| now.duration_since(t) >= D_STATE_REAP_BOUND)
822        {
823            return self.finish(reactor, true).map(Some);
824        }
825        Ok(None)
826    }
827
828    fn advance_cancel(&mut self, now: Instant) -> Result<(), CoreError> {
829        let Some(cancel_at) = self.cancel_at else {
830            return Ok(());
831        };
832        // The child is already reaped — its pid may be recycled. Never signal.
833        if self.status.is_some() {
834            return Ok(());
835        }
836        let running = self
837            .running
838            .as_ref()
839            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
840        let process = &running.process;
841        let pid = process.pid();
842        let pgid = effective_pgid(pid, self.pgroup);
843        let target_is_group = self.pgroup.isolated || self.pgroup.leader.is_some();
844        match self.kill_state {
845            KillState::None => match self.cancel {
846                CancelPolicy::None => {}
847                CancelPolicy::Graceful => {
848                    let result = signal_process(process, target_is_group, pgid, libc::SIGTERM);
849                    self.kill_state = if result.is_ok() {
850                        KillState::TermSent
851                    } else {
852                        KillState::KillSent
853                    };
854                    if self.kill_state == KillState::KillSent {
855                        self.kill_sent_at = Some(now);
856                    }
857                }
858                CancelPolicy::Kill => {
859                    let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
860                    self.kill_state = KillState::KillSent;
861                    self.kill_sent_at = Some(now);
862                }
863            },
864            KillState::TermSent if now >= cancel_at + self.kill_grace => {
865                let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
866                self.kill_state = KillState::KillSent;
867                self.kill_sent_at = Some(now);
868            }
869            _ => {}
870        }
871        Ok(())
872    }
873
874    fn finish(&mut self, reactor: &mut Reactor, force_close: bool) -> Result<Output, CoreError> {
875        let mut running = self
876            .running
877            .take()
878            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
879        for slot in running.drain.take_all_slots() {
880            if force_close {
881                let _ = reactor.del(&slot.fd);
882            } else {
883                reactor.del(&slot.fd)?;
884            }
885        }
886        let pid = running.process.pid();
887        let (stdout, stderr, output_limit_exceeded, stdout_early_exited) =
888            running.drain.into_parts_with_state();
889        // Mirror blocking `spawn`: overflow is reported only when the drain
890        // completed naturally. On the forced-close path (timeout/cancel with a
891        // wedged pipe) the caller gets the partial output and the timed-out
892        // flag instead, matching the blocking N4 behavior.
893        if output_limit_exceeded && !force_close {
894            return Err(CoreError::sys(libc::EOVERFLOW, "spawn output limit"));
895        }
896        Ok(Output {
897            pid,
898            status: self.status.take(),
899            stdout,
900            stderr,
901            timed_out: self.timed_out,
902            stdout_early_exited,
903        })
904    }
905}
906
907impl Drop for ManagedProcess {
908    fn drop(&mut self) {
909        let Some(running) = self.running.take() else {
910            return;
911        };
912        // If the child was already reaped by `poll_completion`, the pid may
913        // have been recycled — never signal it. The pipes are dropped with
914        // `running`, so there is nothing left to clean up.
915        if self.status.is_some() {
916            return;
917        }
918        let process = &running.process;
919        let pid = process.pid();
920        let pgid = effective_pgid(pid, self.pgroup);
921        let target_is_group = self.pgroup.isolated || self.pgroup.leader.is_some();
922        let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
923        // Bound the reap wait: SIGKILL terminates a runnable child
924        // immediately, but a child stuck in uninterruptible sleep (D-state)
925        // never dies. Poll with WNOHANG so `Drop` cannot wedge the caller's
926        // reactor thread forever on a stuck child.
927        let deadline = Instant::now() + Duration::from_millis(100);
928        while Instant::now() < deadline {
929            match process.wait_step() {
930                Ok(Some(_)) => return,
931                Ok(None) => std::thread::sleep(Duration::from_millis(5)),
932                Err(_) => return,
933            }
934        }
935    }
936}
937
938fn effective_pgid(pid: pid_t, pgroup: ProcessGroup) -> pid_t {
939    match pgroup.leader {
940        Some(0) | None => pid,
941        Some(leader) => leader,
942    }
943}
944
945fn signal_process(
946    process: &Process,
947    target_is_group: bool,
948    pgid: pid_t,
949    signal: i32,
950) -> Result<(), CoreError> {
951    if target_is_group {
952        process.kill_group(pgid, signal)
953    } else {
954        process.kill(signal)
955    }
956}
957
958/// Start spawning a process and return a monitor handle.
959///
960/// This initializes the pipes and starts the process, but does not block. Use
961/// [`RunningProcess::register_with_reactor`],
962/// [`RunningProcess::handle_reactor_event`], [`RunningProcess::io_done`], and
963/// [`RunningProcess::into_output_parts`] to drive captured stdio without
964/// exposing internal drain state.
965///
966/// ### Errors
967/// - `EACCES`: Permission denied for the executable.
968/// - `EINVAL`: Invalid spawn options (e.g. background capture without wait).
969/// - `EMFILE`: Process limit on open file descriptors hit.
970/// - `ENOENT`: The executable was not found.
971/// - `ENOMEM`: Insufficient memory to spawn the process.
972pub fn spawn_start(opts: SpawnOptions) -> Result<RunningProcess, CoreError> {
973    if !opts.wait && (opts.stdin.is_some() || opts.capture_stdout || opts.capture_stderr) {
974        return Err(CoreError::sys(
975            libc::EINVAL,
976            "background I/O capture not supported (wait must be true)",
977        ));
978    }
979
980    validate_backend(&opts)?;
981
982    let (pid, drain) = match opts.backend {
983        SpawnBackend::PosixSpawn => spawn_posix_internal(opts)?,
984        SpawnBackend::Fork => spawn_fork_internal(opts)?,
985    };
986
987    Ok(RunningProcess {
988        process: Process::new(pid),
989        drain,
990    })
991}
992
993/// Start a process whose complete lifecycle is driven by a caller-owned
994/// reactor.
995pub fn spawn_managed(opts: SpawnOptions) -> Result<ManagedProcess, CoreError> {
996    if !opts.wait {
997        return Err(CoreError::sys(
998            libc::EINVAL,
999            "managed process requires wait=true",
1000        ));
1001    }
1002    let timeout_at = opts
1003        .timeout_ms
1004        .map(|ms| Instant::now() + Duration::from_millis(ms as u64));
1005    let kill_grace = Duration::from_millis(opts.kill_grace_ms as u64);
1006    let cancel = opts.cancel;
1007    let pgroup = opts.pgroup;
1008    let running = spawn_start(opts)?;
1009    let pid = running.process.pid();
1010    Ok(ManagedProcess {
1011        running: Some(running),
1012        pid,
1013        timeout_at,
1014        kill_grace,
1015        cancel,
1016        pgroup,
1017        cancel_at: None,
1018        kill_state: KillState::None,
1019        status: None,
1020        timed_out: false,
1021        kill_sent_at: None,
1022    })
1023}
1024
1025/// Spawn a process and block until completion or timeout.
1026///
1027/// This is the primary high-level interface for process execution. It handles
1028/// the full lifecycle, including I/O multiplexing and signal management.
1029///
1030/// ### Errors
1031/// Returns the same errors as [`spawn_start`], plus any I/O or reactor errors
1032/// encountered during the wait loop.
1033pub fn spawn(opts: SpawnOptions) -> Result<Output, CoreError> {
1034    let wait = opts.wait;
1035    let timeout_ms = opts.timeout_ms;
1036    let kill_grace_ms = opts.kill_grace_ms;
1037    let cancel = opts.cancel;
1038    let pgroup = opts.pgroup;
1039
1040    let mut reactor = Reactor::new()?;
1041    let running = spawn_start(opts)?;
1042
1043    let pid = running.process.pid();
1044    let mut drain = running.drain;
1045
1046    drain.register_with_reactor(&mut reactor)?;
1047
1048    if !wait {
1049        let (stdout, stderr) = drain.into_parts();
1050        return Ok(Output {
1051            pid,
1052            status: None,
1053            stdout,
1054            stderr,
1055            timed_out: false,
1056            stdout_early_exited: false,
1057        });
1058    }
1059
1060    wait_loop(
1061        pid,
1062        drain,
1063        reactor,
1064        timeout_ms,
1065        kill_grace_ms,
1066        cancel,
1067        pgroup,
1068    )
1069}
1070
1071#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1072enum KillState {
1073    None,
1074    TermSent,
1075    KillSent,
1076}
1077
1078fn wait_loop(
1079    pid: pid_t,
1080    mut drain: crate::io::DrainState<fn(&[u8]) -> bool>,
1081    mut reactor: Reactor,
1082    timeout_ms: Option<u32>,
1083    kill_grace_ms: u32,
1084    cancel: CancelPolicy,
1085    pgroup: ProcessGroup,
1086) -> Result<Output, CoreError> {
1087    let process = Process::new(pid);
1088    // M8: the child's effective pgid is the configured leader when one is set
1089    // (Setpgid is applied after Setsid in the child), else its own pid. A
1090    // timeout must signal `-pgid`; `kill(-pid)` would target a different
1091    // group for a custom leader and the child would never die.
1092    let pgid = effective_pgid(pid, pgroup);
1093    let mut status_raw = process.wait_step()?;
1094    let mut state = KillState::None;
1095    let mut timed_out = false;
1096    // D-state bound: recorded once SIGKILL has been sent. If the child still
1097    // refuses to die (or be reaped) after `D_STATE_REAP_BOUND`, give up and
1098    // return the partial output instead of spinning on a stuck child.
1099    let mut kill_sent_at: Option<Instant> = None;
1100
1101    let start_time = std::time::Instant::now();
1102    let deadline = timeout_ms.map(|t| std::time::Duration::from_millis(t as u64));
1103
1104    loop {
1105        let mut poll_timeout = -1;
1106
1107        if let Some(dl) = deadline {
1108            let elapsed = start_time.elapsed();
1109            if elapsed >= dl {
1110                timed_out = true;
1111                let elapsed_over = (elapsed - dl).as_millis();
1112
1113                let target_is_group = pgroup.isolated || pgroup.leader.is_some();
1114
1115                // Only signal while the child is unreaped. Once waitpid has
1116                // reaped it the pid may already be recycled by the OS — killing
1117                // it would hit an unrelated process. The wedged-pipe path below
1118                // returns the partial output without sending any signal.
1119                if status_raw.is_none() {
1120                    match state {
1121                        KillState::None => {
1122                            if cancel == CancelPolicy::Graceful {
1123                                let r = if target_is_group {
1124                                    process.kill_group(pgid, libc::SIGTERM)
1125                                } else {
1126                                    process.kill(libc::SIGTERM)
1127                                };
1128                                if r.is_err() {
1129                                    state = KillState::KillSent; // Process already gone
1130                                    kill_sent_at = Some(Instant::now());
1131                                } else {
1132                                    state = KillState::TermSent;
1133                                }
1134                            } else if cancel == CancelPolicy::Kill {
1135                                let _ = if target_is_group {
1136                                    process.kill_group(pgid, libc::SIGKILL)
1137                                } else {
1138                                    process.kill(libc::SIGKILL)
1139                                };
1140                                state = KillState::KillSent;
1141                                kill_sent_at = Some(Instant::now());
1142                            } else {
1143                                // CancelPolicy::None just times out without killing
1144                            }
1145                        }
1146                        KillState::TermSent if elapsed_over > kill_grace_ms as u128 => {
1147                            let _ = if target_is_group {
1148                                process.kill_group(pgid, libc::SIGKILL)
1149                            } else {
1150                                process.kill(libc::SIGKILL)
1151                            };
1152                            state = KillState::KillSent;
1153                            kill_sent_at = Some(Instant::now());
1154                        }
1155                        _ => {}
1156                    }
1157                }
1158                poll_timeout = 100; // Poll frequently while waiting for kill to take effect
1159            } else {
1160                let remaining = dl - elapsed;
1161                poll_timeout = remaining.as_millis().min(i32::MAX as u128) as i32;
1162            }
1163        }
1164
1165        if status_raw.is_none()
1166            && let Some(s) = process.wait_step()?
1167        {
1168            status_raw = Some(s);
1169        }
1170
1171        if drain.is_done() {
1172            let s = if status_raw.is_some() {
1173                status_raw.take()
1174            } else if deadline.is_none() {
1175                // C1: all pipes drained but the child is still alive, and no
1176                // deadline is set → block until it exits (intended semantics).
1177                Some(process.wait_blocking()?)
1178            } else {
1179                // C1: pipes drained with a deadline set → never block here; fall
1180                // through to the bounded `reactor.wait` below so the deadline
1181                // logic at the top of the loop kills and reaps. A later
1182                // `wait_step` reaps the child and we return from this branch.
1183                None
1184            };
1185
1186            if let Some(s) = s {
1187                for slot in drain.take_all_slots() {
1188                    reactor.del(&slot.fd)?;
1189                }
1190                let (stdout, stderr, output_limit_exceeded, stdout_early_exited) =
1191                    drain.into_parts_with_state();
1192                if output_limit_exceeded {
1193                    return Err(CoreError::sys(libc::EOVERFLOW, "spawn output limit"));
1194                }
1195                return Ok(Output {
1196                    pid,
1197                    status: Some(s),
1198                    stdout,
1199                    stderr,
1200                    timed_out,
1201                    stdout_early_exited,
1202                });
1203            }
1204        }
1205
1206        // N4: the deadline has elapsed and the child is reaped, but a wedged
1207        // pipe (a descendant inheriting the write end) keeps the drain from
1208        // closing. The absolute deadline is authoritative — return the partial
1209        // output instead of spinning forever.
1210        if timed_out && status_raw.is_some() {
1211            for slot in drain.take_all_slots() {
1212                let _ = reactor.del(&slot.fd);
1213            }
1214            let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
1215                drain.into_parts_with_state();
1216            return Ok(Output {
1217                pid,
1218                status: status_raw,
1219                stdout,
1220                stderr,
1221                timed_out: true,
1222                stdout_early_exited,
1223            });
1224        }
1225
1226        // D-state: SIGKILL has been sent but the child is still unreaped after
1227        // the bound. A child stuck in uninterruptible sleep keeps the signal
1228        // pending until it leaves D-state, so no further wait can succeed —
1229        // return the partial output rather than polling forever. The pid is
1230        // not signaled again (it may be recycled once it finally exits).
1231        if let Some(sent_at) = kill_sent_at
1232            && sent_at.elapsed() >= D_STATE_REAP_BOUND
1233            && status_raw.is_none()
1234        {
1235            for slot in drain.take_all_slots() {
1236                let _ = reactor.del(&slot.fd);
1237            }
1238            let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
1239                drain.into_parts_with_state();
1240            return Ok(Output {
1241                pid,
1242                status: None,
1243                stdout,
1244                stderr,
1245                timed_out: true,
1246                stdout_early_exited,
1247            });
1248        }
1249
1250        let timeout = poll_timeout;
1251
1252        let mut events = Vec::new();
1253        let nevents = reactor.wait(&mut events, 64, timeout)?;
1254
1255        for ev in events.iter().take(nevents) {
1256            if drain.stdout_matches(ev.token) {
1257                if ev.readable || ev.hangup {
1258                    drain.handle_stdout_ready(&mut reactor)?;
1259                } else if ev.error {
1260                    drain.drop_stdout(&mut reactor)?;
1261                }
1262            } else if drain.stderr_matches(ev.token) {
1263                if ev.readable || ev.hangup {
1264                    drain.handle_stderr_ready(&mut reactor)?;
1265                } else if ev.error {
1266                    drain.drop_stderr(&mut reactor)?;
1267                }
1268            } else if drain.stdin_matches(ev.token) {
1269                if ev.writable {
1270                    drain.handle_stdin_writable(&mut reactor)?;
1271                } else if ev.error || ev.hangup {
1272                    drain.drop_stdin(&mut reactor)?;
1273                }
1274            }
1275        }
1276    }
1277}