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