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 posix;
26mod fork;
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.
257    /// - `EPERM`: The caller does not have permission to send the signal.
258    /// - `ESRCH`: The process does not exist.
259    pub fn kill(&self, sig: i32) -> Result<(), CoreError> {
260        let r = unsafe { libc::kill(self.pid, sig) };
261        if r < 0 {
262            let e = errno();
263            if e == libc::ESRCH {
264                return Ok(());
265            }
266            syscall_ret(-1, "kill")?;
267        }
268        Ok(())
269    }
270
271    /// Signal the process group whose id equals [`Self::pid`] — valid only
272    /// when the process is its own group/session leader. For a child placed
273    /// into a custom leader's group use [`Self::kill_group`].
274    ///
275    /// ### Errors
276    /// Same as [`Self::kill`].
277    pub fn kill_pgroup(&self, sig: i32) -> Result<(), CoreError> {
278        self.kill_group(self.pid, sig)
279    }
280
281    /// Send a signal to an explicit process group.
282    ///
283    /// The pgid must be the child's actual group (its own pid after `setsid`,
284    /// or the configured leader's id after `setpgid`), never guessed from the
285    /// pid.
286    ///
287    /// ### Errors
288    /// Same as [`Self::kill`].
289    pub fn kill_group(&self, pgid: pid_t, sig: i32) -> Result<(), CoreError> {
290        let r = unsafe { libc::kill(-pgid, sig) };
291        if r < 0 {
292            let e = errno();
293            if e == libc::ESRCH {
294                return Ok(());
295            }
296            syscall_ret(-1, "kill_group")?;
297        }
298        Ok(())
299    }
300}
301
302/// Configuration options for spawning a new process.
303#[derive(Clone)]
304pub struct SpawnOptions {
305    ctx: ExecContext,
306    stdin: Option<Box<[u8]>>,
307    capture_stdout: bool,
308    capture_stderr: bool,
309    wait: bool,
310    pgroup: ProcessGroup,
311    max_output: usize,
312    timeout_ms: Option<u32>,
313    kill_grace_ms: u32,
314    cancel: CancelPolicy,
315    backend: SpawnBackend,
316    fd_policy: SpawnFdPolicy,
317    early_exit: Option<fn(&[u8]) -> bool>,
318}
319
320impl SpawnOptions {
321    /// Create a new builder for process spawning.
322    pub fn builder(argv: Vec<String>, backend: SpawnBackend) -> SpawnOptionsBuilder {
323        SpawnOptionsBuilder::new(argv, backend)
324    }
325
326    /// Execute the process according to the options and block until completion.
327    pub fn run(self) -> Result<Output, CoreError> {
328        spawn(self)
329    }
330}
331
332/// Builder for [`SpawnOptions`].
333#[derive(Clone)]
334pub struct SpawnOptionsBuilder {
335    argv: Vec<String>,
336    env: Option<Vec<String>>,
337    cwd: Option<String>,
338    stdin: Option<Box<[u8]>>,
339    capture_stdout: bool,
340    capture_stderr: bool,
341    wait: bool,
342    pgroup: ProcessGroup,
343    max_output: usize,
344    timeout_ms: Option<u32>,
345    kill_grace_ms: u32,
346    cancel: CancelPolicy,
347    backend: SpawnBackend,
348    fd_policy: SpawnFdPolicy,
349    early_exit: Option<fn(&[u8]) -> bool>,
350}
351
352impl SpawnOptionsBuilder {
353    /// Create a new builder with the specified argument vector.
354    pub fn new(argv: Vec<String>, backend: SpawnBackend) -> Self {
355        Self {
356            argv,
357            env: None,
358            cwd: None,
359            stdin: None,
360            capture_stdout: false,
361            capture_stderr: false,
362            wait: true,
363            pgroup: ProcessGroup::default(),
364            max_output: 1024 * 1024,
365            timeout_ms: None,
366            kill_grace_ms: 2000,
367            cancel: CancelPolicy::Kill,
368            backend,
369            fd_policy: SpawnFdPolicy::default(),
370            early_exit: None,
371        }
372    }
373
374    /// Set environment variables.
375    pub fn env(mut self, env: Vec<String>) -> Self {
376        self.env = Some(env);
377        self
378    }
379
380    /// Set the working directory.
381    pub fn cwd(mut self, cwd: String) -> Self {
382        self.cwd = Some(cwd);
383        self
384    }
385
386    /// Provide data to be written to the child's stdin.
387    pub fn stdin(mut self, data: impl Into<Box<[u8]>>) -> Self {
388        self.stdin = Some(data.into());
389        self
390    }
391
392    /// Enable stdout capture.
393    pub fn capture_stdout(mut self) -> Self {
394        self.capture_stdout = true;
395        self
396    }
397
398    /// Enable stderr capture.
399    pub fn capture_stderr(mut self) -> Self {
400        self.capture_stderr = true;
401        self
402    }
403
404    /// Set whether to wait for the process to terminate (default: true).
405    pub fn wait(mut self, wait: bool) -> Self {
406        self.wait = wait;
407        self
408    }
409
410    /// Set process group and isolation policy.
411    pub fn pgroup(mut self, pgroup: ProcessGroup) -> Self {
412        self.pgroup = pgroup;
413        self
414    }
415
416    /// Set the combined stdout+stderr output buffer size (default: 1MB).
417    ///
418    /// If captured output exceeds this limit, spawn drains the child pipes to
419    /// completion and returns `EOVERFLOW`.
420    pub fn max_output(mut self, max: usize) -> Self {
421        self.max_output = max;
422        self
423    }
424
425    /// Set the execution timeout in milliseconds.
426    pub fn timeout_ms(mut self, ms: u32) -> Self {
427        self.timeout_ms = Some(ms);
428        self
429    }
430
431    /// Set the grace period before SIGKILL (default: 2s).
432    pub fn kill_grace_ms(mut self, ms: u32) -> Self {
433        self.kill_grace_ms = ms;
434        self
435    }
436
437    /// Set the cancellation policy (default: Kill).
438    pub fn cancel(mut self, policy: CancelPolicy) -> Self {
439        self.cancel = policy;
440        self
441    }
442
443    /// Set the child file-descriptor inheritance policy.
444    pub fn fd_policy(mut self, policy: SpawnFdPolicy) -> Self {
445        self.fd_policy = policy;
446        self
447    }
448
449    /// Set an early exit callback.
450    pub fn early_exit(mut self, callback: fn(&[u8]) -> bool) -> Self {
451        self.early_exit = Some(callback);
452        self
453    }
454
455    /// Build the spawn options.
456    pub fn build(self) -> Result<SpawnOptions, CoreError> {
457        let ctx = ExecContext::new(self.argv, self.env, self.cwd)?;
458        Ok(SpawnOptions {
459            ctx,
460            stdin: self.stdin,
461            capture_stdout: self.capture_stdout,
462            capture_stderr: self.capture_stderr,
463            wait: self.wait,
464            pgroup: self.pgroup,
465            max_output: self.max_output,
466            timeout_ms: self.timeout_ms,
467            kill_grace_ms: self.kill_grace_ms,
468            cancel: self.cancel,
469            backend: self.backend,
470            fd_policy: self.fd_policy,
471            early_exit: self.early_exit,
472        })
473    }
474}
475
476/// The result of a process execution.
477#[derive(Debug)]
478pub struct Output {
479    /// The PID of the finished process.
480    pub pid: pid_t,
481    /// Final exit status (None if `wait=false`).
482    pub status: Option<ExitStatus>,
483    /// Captured stdout buffer.
484    pub stdout: Vec<u8>,
485    /// Captured stderr buffer.
486    pub stderr: Vec<u8>,
487    /// Whether the process timed out.
488    pub timed_out: bool,
489    /// Whether stdout drain stopped because the early-exit callback matched.
490    pub stdout_early_exited: bool,
491}
492
493fn validate_backend(opts: &SpawnOptions) -> Result<(), CoreError> {
494    validate_fd_policy(&opts.fd_policy)?;
495    match opts.backend {
496        SpawnBackend::PosixSpawn => {
497            if opts.ctx.cwd.is_some() {
498                return Err(CoreError::sys(libc::EINVAL, "posix_spawn cwd unsupported"));
499            }
500            if opts.pgroup.isolated {
501                return Err(CoreError::sys(
502                    libc::EINVAL,
503                    "posix_spawn setsid unsupported",
504                ));
505            }
506            if opts.fd_policy != SpawnFdPolicy::CloexecOnly {
507                return Err(CoreError::sys(
508                    libc::EINVAL,
509                    "posix_spawn fd policy unsupported",
510                ));
511            }
512            Ok(())
513        }
514        SpawnBackend::Fork => Ok(()),
515    }
516}
517
518fn validate_fd_policy(policy: &SpawnFdPolicy) -> Result<(), CoreError> {
519    if let SpawnFdPolicy::Allowlist(fds) = policy {
520        let mut seen = Vec::with_capacity(fds.len());
521        for &fd in fds {
522            if fd < 0 {
523                return Err(CoreError::sys(libc::EINVAL, "spawn fd allowlist invalid"));
524            }
525            let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
526            if flags < 0 {
527                return Err(CoreError::sys(errno(), "spawn fd allowlist fcntl(F_GETFD)"));
528            }
529            if seen.contains(&fd) {
530                return Err(CoreError::sys(libc::EINVAL, "spawn fd allowlist duplicate"));
531            }
532            seen.push(fd);
533        }
534    }
535    Ok(())
536}
537
538/// Specialized drain state for process spawning.
539pub type SpawnDrain = DrainState<fn(&[u8]) -> bool>;
540
541/// A process that is currently running and being monitored.
542///
543/// ### Fork Safety
544/// This handle contains both a PID and owned file descriptors for process I/O.
545/// Upon `fork`, the descriptors are inherited. Standard `O_CLOEXEC` behavior
546/// applies after `exec`.
547pub struct RunningProcess {
548    /// Handle to the process.
549    pub process: Process,
550    drain: SpawnDrain,
551}
552
553/// Full process lifecycle driven by a caller-owned reactor.
554///
555/// `ManagedProcess` preserves the blocking [`spawn`] semantics while allowing
556/// an application reactor to stay responsive: Core owns timeout/cancellation
557/// escalation, process-group signaling, pipe draining, overflow reporting, and
558/// `waitpid` reaping; the caller only routes readiness events and polls on
559/// [`Self::next_deadline`].
560pub struct ManagedProcess {
561    running: Option<RunningProcess>,
562    timeout_at: Option<Instant>,
563    kill_grace: Duration,
564    cancel: CancelPolicy,
565    pgroup: ProcessGroup,
566    cancel_at: Option<Instant>,
567    kill_state: KillState,
568    status: Option<ExitStatus>,
569    timed_out: bool,
570}
571
572impl RunningProcess {
573    /// Register active stdio pipe descriptors with a reactor.
574    ///
575    /// Call this once after [`spawn_start`] when the process was started with
576    /// captured output or stdin data. The assigned tokens are kept internally
577    /// and later matched by [`Self::handle_reactor_event`].
578    pub fn register_with_reactor(&mut self, reactor: &mut Reactor) -> Result<(), CoreError> {
579        self.drain.register_with_reactor(reactor)
580    }
581
582    /// Apply one reactor readiness event to this process' stdio drain state.
583    ///
584    /// Events for unrelated tokens are ignored. Callers remain responsible for
585    /// waiting on [`Self::process`] and driving the reactor until [`Self::io_done`]
586    /// returns true.
587    pub fn handle_reactor_event(
588        &mut self,
589        reactor: &mut Reactor,
590        event: &crate::fd::Event,
591    ) -> Result<(), CoreError> {
592        if self.drain.stdout_matches(event.token) {
593            if event.readable || event.hangup {
594                self.drain.handle_stdout_ready(reactor)?;
595            } else if event.error {
596                self.drain.drop_stdout(reactor)?;
597            }
598        } else if self.drain.stderr_matches(event.token) {
599            if event.readable || event.hangup {
600                self.drain.handle_stderr_ready(reactor)?;
601            } else if event.error {
602                self.drain.drop_stderr(reactor)?;
603            }
604        } else if self.drain.stdin_matches(event.token) {
605            if event.writable {
606                self.drain.handle_stdin_writable(reactor)?;
607            } else if event.error || event.hangup {
608                self.drain.drop_stdin(reactor)?;
609            }
610        }
611        Ok(())
612    }
613
614    /// Return whether all managed stdio pipes have been drained or closed.
615    pub fn io_done(&self) -> bool {
616        self.drain.is_done()
617    }
618
619    /// Consume the running process handle and return captured stdout/stderr buffers.
620    pub fn into_output_parts(self) -> (Vec<u8>, Vec<u8>) {
621        self.drain.into_parts()
622    }
623}
624
625impl ManagedProcess {
626    /// Return the child PID.
627    pub fn pid(&self) -> pid_t {
628        self.running
629            .as_ref()
630            .expect("managed process already completed")
631            .process
632            .pid()
633    }
634
635    /// Register active child I/O descriptors with the caller's reactor.
636    pub fn register_with_reactor(&mut self, reactor: &mut Reactor) -> Result<(), CoreError> {
637        self.running
638            .as_mut()
639            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
640            .register_with_reactor(reactor)
641    }
642
643    /// Route one reactor event to the child's I/O drain state.
644    pub fn handle_reactor_event(
645        &mut self,
646        reactor: &mut Reactor,
647        event: &crate::fd::Event,
648    ) -> Result<(), CoreError> {
649        self.running
650            .as_mut()
651            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?
652            .handle_reactor_event(reactor, event)
653    }
654
655    /// Request cancellation using the daemon-owned policy from
656    /// [`SpawnOptionsBuilder::cancel`]. Repeated requests are idempotent.
657    pub fn request_cancel(&mut self) {
658        self.cancel_at.get_or_insert_with(Instant::now);
659    }
660
661    /// Earliest time at which [`Self::poll_completion`] should run again.
662    ///
663    /// A bounded reap tick is returned while the child is live, and exact
664    /// timeout / TERM-to-KILL deadlines take precedence. `None` means the
665    /// completion was already consumed.
666    pub fn next_deadline(&self) -> Option<Instant> {
667        self.running.as_ref()?;
668        let now = Instant::now();
669        let mut next = now + Duration::from_millis(100);
670        if !self.timed_out
671            && let Some(timeout_at) = self.timeout_at
672            && timeout_at < next
673        {
674            next = timeout_at;
675        }
676        if self.kill_state == KillState::TermSent
677            && let Some(cancel_at) = self.cancel_at
678        {
679            let kill_at = cancel_at + self.kill_grace;
680            if kill_at < next {
681                next = kill_at;
682            }
683        }
684        Some(next)
685    }
686
687    /// Advance timeout/cancellation, reap state, and completion.
688    ///
689    /// Returns `Ok(None)` while work remains, the normal [`Output`] once the
690    /// child is reaped and its pipes are drained, or `EOVERFLOW` when the
691    /// configured combined output limit was exceeded.
692    pub fn poll_completion(&mut self, reactor: &mut Reactor) -> Result<Option<Output>, CoreError> {
693        let now = Instant::now();
694        if !self.timed_out
695            && let Some(timeout_at) = self.timeout_at
696            && now >= timeout_at
697        {
698            self.timed_out = true;
699            self.cancel_at.get_or_insert(timeout_at);
700        }
701
702        self.advance_cancel(now)?;
703
704        let running = self
705            .running
706            .as_ref()
707            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
708        if self.status.is_none() {
709            self.status = running.process.wait_step()?;
710        }
711
712        let io_done = running.io_done();
713        if self.status.is_some() && (io_done || self.cancel_at.is_some()) {
714            return self.finish(reactor, !io_done).map(Some);
715        }
716        Ok(None)
717    }
718
719    fn advance_cancel(&mut self, now: Instant) -> Result<(), CoreError> {
720        let Some(cancel_at) = self.cancel_at else {
721            return Ok(());
722        };
723        let running = self
724            .running
725            .as_ref()
726            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
727        let process = &running.process;
728        let pid = process.pid();
729        let pgid = effective_pgid(pid, self.pgroup);
730        let target_is_group = self.pgroup.isolated || self.pgroup.leader.is_some();
731        match self.kill_state {
732            KillState::None => match self.cancel {
733                CancelPolicy::None => {}
734                CancelPolicy::Graceful => {
735                    let result = signal_process(process, target_is_group, pgid, libc::SIGTERM);
736                    self.kill_state = if result.is_ok() {
737                        KillState::TermSent
738                    } else {
739                        KillState::KillSent
740                    };
741                }
742                CancelPolicy::Kill => {
743                    let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
744                    self.kill_state = KillState::KillSent;
745                }
746            },
747            KillState::TermSent if now >= cancel_at + self.kill_grace => {
748                let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
749                self.kill_state = KillState::KillSent;
750            }
751            _ => {}
752        }
753        Ok(())
754    }
755
756    fn finish(&mut self, reactor: &mut Reactor, force_close: bool) -> Result<Output, CoreError> {
757        let mut running = self
758            .running
759            .take()
760            .ok_or_else(|| CoreError::sys(libc::EINVAL, "managed process completed"))?;
761        for slot in running.drain.take_all_slots() {
762            if force_close {
763                let _ = reactor.del(&slot.fd);
764            } else {
765                reactor.del(&slot.fd)?;
766            }
767        }
768        let pid = running.process.pid();
769        let (stdout, stderr, output_limit_exceeded, stdout_early_exited) =
770            running.drain.into_parts_with_state();
771        if output_limit_exceeded {
772            return Err(CoreError::sys(libc::EOVERFLOW, "spawn output limit"));
773        }
774        Ok(Output {
775            pid,
776            status: self.status.take(),
777            stdout,
778            stderr,
779            timed_out: self.timed_out,
780            stdout_early_exited,
781        })
782    }
783}
784
785impl Drop for ManagedProcess {
786    fn drop(&mut self) {
787        let Some(running) = self.running.take() else {
788            return;
789        };
790        let process = &running.process;
791        let pid = process.pid();
792        let pgid = effective_pgid(pid, self.pgroup);
793        let target_is_group = self.pgroup.isolated || self.pgroup.leader.is_some();
794        let _ = signal_process(process, target_is_group, pgid, libc::SIGKILL);
795        let _ = process.wait_blocking();
796    }
797}
798
799fn effective_pgid(pid: pid_t, pgroup: ProcessGroup) -> pid_t {
800    match pgroup.leader {
801        Some(0) | None => pid,
802        Some(leader) => leader,
803    }
804}
805
806fn signal_process(
807    process: &Process,
808    target_is_group: bool,
809    pgid: pid_t,
810    signal: i32,
811) -> Result<(), CoreError> {
812    if target_is_group {
813        process.kill_group(pgid, signal)
814    } else {
815        process.kill(signal)
816    }
817}
818
819/// Start spawning a process and return a monitor handle.
820///
821/// This initializes the pipes and starts the process, but does not block. Use
822/// [`RunningProcess::register_with_reactor`],
823/// [`RunningProcess::handle_reactor_event`], [`RunningProcess::io_done`], and
824/// [`RunningProcess::into_output_parts`] to drive captured stdio without
825/// exposing internal drain state.
826///
827/// ### Errors
828/// - `EACCES`: Permission denied for the executable.
829/// - `EINVAL`: Invalid spawn options (e.g. background capture without wait).
830/// - `EMFILE`: Process limit on open file descriptors hit.
831/// - `ENOENT`: The executable was not found.
832/// - `ENOMEM`: Insufficient memory to spawn the process.
833pub fn spawn_start(opts: SpawnOptions) -> Result<RunningProcess, CoreError> {
834    if !opts.wait && (opts.stdin.is_some() || opts.capture_stdout || opts.capture_stderr) {
835        return Err(CoreError::sys(
836            libc::EINVAL,
837            "background I/O capture not supported (wait must be true)",
838        ));
839    }
840
841    validate_backend(&opts)?;
842
843    let (pid, drain) = match opts.backend {
844        SpawnBackend::PosixSpawn => spawn_posix_internal(opts)?,
845        SpawnBackend::Fork => spawn_fork_internal(opts)?,
846    };
847
848    Ok(RunningProcess {
849        process: Process::new(pid),
850        drain,
851    })
852}
853
854/// Start a process whose complete lifecycle is driven by a caller-owned
855/// reactor.
856pub fn spawn_managed(opts: SpawnOptions) -> Result<ManagedProcess, CoreError> {
857    if !opts.wait {
858        return Err(CoreError::sys(
859            libc::EINVAL,
860            "managed process requires wait=true",
861        ));
862    }
863    let timeout_at = opts
864        .timeout_ms
865        .map(|ms| Instant::now() + Duration::from_millis(ms as u64));
866    let kill_grace = Duration::from_millis(opts.kill_grace_ms as u64);
867    let cancel = opts.cancel;
868    let pgroup = opts.pgroup;
869    let running = spawn_start(opts)?;
870    Ok(ManagedProcess {
871        running: Some(running),
872        timeout_at,
873        kill_grace,
874        cancel,
875        pgroup,
876        cancel_at: None,
877        kill_state: KillState::None,
878        status: None,
879        timed_out: false,
880    })
881}
882
883/// Spawn a process and block until completion or timeout.
884///
885/// This is the primary high-level interface for process execution. It handles
886/// the full lifecycle, including I/O multiplexing and signal management.
887///
888/// ### Errors
889/// Returns the same errors as [`spawn_start`], plus any I/O or reactor errors
890/// encountered during the wait loop.
891pub fn spawn(opts: SpawnOptions) -> Result<Output, CoreError> {
892    let wait = opts.wait;
893    let timeout_ms = opts.timeout_ms;
894    let kill_grace_ms = opts.kill_grace_ms;
895    let cancel = opts.cancel;
896    let pgroup = opts.pgroup;
897
898    let mut reactor = Reactor::new()?;
899    let running = spawn_start(opts)?;
900
901    let pid = running.process.pid();
902    let mut drain = running.drain;
903
904    drain.register_with_reactor(&mut reactor)?;
905
906    if !wait {
907        let (stdout, stderr) = drain.into_parts();
908        return Ok(Output {
909            pid,
910            status: None,
911            stdout,
912            stderr,
913            timed_out: false,
914            stdout_early_exited: false,
915        });
916    }
917
918    wait_loop(
919        pid,
920        drain,
921        reactor,
922        timeout_ms,
923        kill_grace_ms,
924        cancel,
925        pgroup,
926    )
927}
928
929#[derive(Debug, Clone, Copy, PartialEq, Eq)]
930enum KillState {
931    None,
932    TermSent,
933    KillSent,
934}
935
936fn wait_loop(
937    pid: pid_t,
938    mut drain: crate::io::DrainState<fn(&[u8]) -> bool>,
939    mut reactor: Reactor,
940    timeout_ms: Option<u32>,
941    kill_grace_ms: u32,
942    cancel: CancelPolicy,
943    pgroup: ProcessGroup,
944) -> Result<Output, CoreError> {
945    let process = Process::new(pid);
946    // M8: the child's effective pgid is the configured leader when one is set
947    // (Setpgid is applied after Setsid in the child), else its own pid. A
948    // timeout must signal `-pgid`; `kill(-pid)` would target a different
949    // group for a custom leader and the child would never die.
950    let pgid = effective_pgid(pid, pgroup);
951    let mut status_raw = process.wait_step()?;
952    let mut state = KillState::None;
953    let mut timed_out = false;
954
955    let start_time = std::time::Instant::now();
956    let deadline = timeout_ms.map(|t| std::time::Duration::from_millis(t as u64));
957
958    loop {
959        let mut poll_timeout = -1;
960
961        if let Some(dl) = deadline {
962            let elapsed = start_time.elapsed();
963            if elapsed >= dl {
964                timed_out = true;
965                let elapsed_over = (elapsed - dl).as_millis();
966
967                let target_is_group = pgroup.isolated || pgroup.leader.is_some();
968
969                match state {
970                    KillState::None => {
971                        if cancel == CancelPolicy::Graceful {
972                            let r = if target_is_group {
973                                process.kill_group(pgid, libc::SIGTERM)
974                            } else {
975                                process.kill(libc::SIGTERM)
976                            };
977                            if r.is_err() {
978                                state = KillState::KillSent; // Process already gone
979                            } else {
980                                state = KillState::TermSent;
981                            }
982                        } else if cancel == CancelPolicy::Kill {
983                            let _ = if target_is_group {
984                                process.kill_group(pgid, libc::SIGKILL)
985                            } else {
986                                process.kill(libc::SIGKILL)
987                            };
988                            state = KillState::KillSent;
989                        } else {
990                            // CancelPolicy::None just times out without killing
991                        }
992                    }
993                    KillState::TermSent if elapsed_over > kill_grace_ms as u128 => {
994                        let _ = if target_is_group {
995                            process.kill_group(pgid, libc::SIGKILL)
996                        } else {
997                            process.kill(libc::SIGKILL)
998                        };
999                        state = KillState::KillSent;
1000                    }
1001                    _ => {}
1002                }
1003                poll_timeout = 100; // Poll frequently while waiting for kill to take effect
1004            } else {
1005                let remaining = dl - elapsed;
1006                poll_timeout = remaining.as_millis().min(i32::MAX as u128) as i32;
1007            }
1008        }
1009
1010        if status_raw.is_none()
1011            && let Some(s) = process.wait_step()?
1012        {
1013            status_raw = Some(s);
1014        }
1015
1016        if drain.is_done() {
1017            let s = if status_raw.is_some() {
1018                status_raw.take()
1019            } else if deadline.is_none() {
1020                // C1: all pipes drained but the child is still alive, and no
1021                // deadline is set → block until it exits (intended semantics).
1022                Some(process.wait_blocking()?)
1023            } else {
1024                // C1: pipes drained with a deadline set → never block here; fall
1025                // through to the bounded `reactor.wait` below so the deadline
1026                // logic at the top of the loop kills and reaps. A later
1027                // `wait_step` reaps the child and we return from this branch.
1028                None
1029            };
1030
1031            if let Some(s) = s {
1032                for slot in drain.take_all_slots() {
1033                    reactor.del(&slot.fd)?;
1034                }
1035                let (stdout, stderr, output_limit_exceeded, stdout_early_exited) =
1036                    drain.into_parts_with_state();
1037                if output_limit_exceeded {
1038                    return Err(CoreError::sys(libc::EOVERFLOW, "spawn output limit"));
1039                }
1040                return Ok(Output {
1041                    pid,
1042                    status: Some(s),
1043                    stdout,
1044                    stderr,
1045                    timed_out,
1046                    stdout_early_exited,
1047                });
1048            }
1049        }
1050
1051        // N4: the deadline has elapsed and the child is reaped, but a wedged
1052        // pipe (a descendant inheriting the write end) keeps the drain from
1053        // closing. The absolute deadline is authoritative — return the partial
1054        // output instead of spinning forever.
1055        if timed_out && status_raw.is_some() {
1056            for slot in drain.take_all_slots() {
1057                let _ = reactor.del(&slot.fd);
1058            }
1059            let (stdout, stderr, _output_limit_exceeded, stdout_early_exited) =
1060                drain.into_parts_with_state();
1061            return Ok(Output {
1062                pid,
1063                status: status_raw,
1064                stdout,
1065                stderr,
1066                timed_out: true,
1067                stdout_early_exited,
1068            });
1069        }
1070
1071        let timeout = poll_timeout;
1072
1073        let mut events = Vec::new();
1074        let nevents = reactor.wait(&mut events, 64, timeout)?;
1075
1076        for ev in events.iter().take(nevents) {
1077            if drain.stdout_matches(ev.token) {
1078                if ev.readable || ev.hangup {
1079                    drain.handle_stdout_ready(&mut reactor)?;
1080                } else if ev.error {
1081                    drain.drop_stdout(&mut reactor)?;
1082                }
1083            } else if drain.stderr_matches(ev.token) {
1084                if ev.readable || ev.hangup {
1085                    drain.handle_stderr_ready(&mut reactor)?;
1086                } else if ev.error {
1087                    drain.drop_stderr(&mut reactor)?;
1088                }
1089            } else if drain.stdin_matches(ev.token) {
1090                if ev.writable {
1091                    drain.handle_stdin_writable(&mut reactor)?;
1092                } else if ev.error || ev.hangup {
1093                    drain.drop_stdin(&mut reactor)?;
1094                }
1095            }
1096        }
1097    }
1098}