Skip to main content

haz_exec/
process.rs

1//! [`ProcessSpawner`] trait, [`Process`] handle trait, and the
2//! supporting value types every implementation produces.
3//!
4//! The trait pair abstracts the executor's view of the host OS's
5//! process facilities so the production backend and the scriptable
6//! test-double mock can share one shape. The production backend
7//! [`StdProcessSpawner`](crate::std_impl::StdProcessSpawner) wraps
8//! [`tokio::process::Command`]; the mock lives alongside under
9//! [`crate::mock_impl`] but is gated to test builds only and is not
10//! part of the crate's stable public surface. The trait associated
11//! types still carry tokio types ([`tokio::io::AsyncRead`]); the
12//! executor is tokio-bound by design and does not aim to be
13//! runtime-neutral.
14//!
15//! Stream ownership is structural rather than convention. The
16//! [`Spawned`] value returned from [`ProcessSpawner::spawn`] carries
17//! the child's stdout and stderr as fields; callers move them into
18//! reader tasks while keeping the [`Process`] value for waiting and
19//! signalling. This mirrors the take-once invariant of
20//! [`tokio::process::Child::stdout`] without exposing it as a runtime
21//! check.
22
23use std::ffi::OsString;
24use std::future::Future;
25use std::path::PathBuf;
26
27use snafu::Snafu;
28use tokio::io::AsyncRead;
29
30/// Numeric process identifier as reported by the host OS.
31///
32/// Wraps [`u32`] so the type system distinguishes a process id from
33/// every other numeric identifier the codebase carries.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub struct ProcessId(pub u32);
36
37/// Termination or cancellation signal the executor delivers to a
38/// child process during the cancellation flow (`EXEC-013`,
39/// `EXEC-014`).
40///
41/// The variants are intentionally limited to the signals the executor
42/// actually uses; this is not a full POSIX signal vocabulary.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum Signal {
45    /// `SIGINT`, the interactive interrupt (e.g. Ctrl+C). Mapped to
46    /// the platform equivalent on non-Unix hosts.
47    Interrupt,
48    /// `SIGTERM`, the polite-termination signal. Mapped to the
49    /// platform equivalent on non-Unix hosts.
50    Terminate,
51    /// `SIGKILL`, the unconditional kill.
52    Kill,
53}
54
55/// Final state of a child process as reported by [`Process::wait`].
56///
57/// Aliased to [`std::process::ExitStatus`] so callers can use the
58/// platform-extension traits (e.g. `ExitStatusExt::signal()` on Unix)
59/// without an extra conversion layer.
60pub type ExitStatus = std::process::ExitStatus;
61
62/// Description of one process the executor wants to spawn.
63///
64/// Carries the program, its argument vector, the working directory
65/// (always absolute), and the environment variables the child should
66/// see. The [`Self::env`] vector is preserved verbatim by all
67/// implementations so test assertions are deterministic.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct SpawnPlan {
70    /// The executable to launch. May be an absolute path
71    /// (e.g. `/bin/sh`) or a name to be resolved via `PATH`
72    /// (e.g. `echo`).
73    pub program: OsString,
74    /// The argument vector passed to the child. Does NOT include
75    /// `argv[0]`; implementations supply that from [`Self::program`].
76    pub args: Vec<OsString>,
77    /// Environment variables the child receives. Order is preserved;
78    /// when the same name appears twice, last-write-wins (matching
79    /// [`std::process::Command::env`] semantics).
80    pub env: Vec<(OsString, OsString)>,
81    /// Working directory the child runs in. MUST be absolute.
82    pub cwd: PathBuf,
83}
84
85/// Failure modes shared by every [`ProcessSpawner`] and [`Process`]
86/// method.
87#[derive(Debug, Snafu)]
88#[snafu(visibility(pub(crate)))]
89pub enum ProcessError {
90    /// The cwd in the [`SpawnPlan`] was relative; absolute paths are
91    /// required.
92    #[snafu(display("expected absolute cwd, got: {}", cwd.display()))]
93    NonAbsoluteCwd {
94        /// Relative cwd that was rejected.
95        cwd: PathBuf,
96    },
97
98    /// The OS refused to spawn the child (executable not found,
99    /// permission denied, fork/exec failure, etc.).
100    #[snafu(display(
101        "failed to spawn process for: {}: {source}",
102        program.to_string_lossy()
103    ))]
104    SpawnFailed {
105        /// Program the executor tried to launch.
106        program: OsString,
107        /// Underlying I/O error.
108        source: std::io::Error,
109    },
110
111    /// The OS refused to deliver a signal to the child, or the
112    /// implementation does not support the requested signal on the
113    /// host platform (e.g. SIGTERM / SIGINT on Windows).
114    #[snafu(display("failed to deliver signal {signal:?} to pid {pid:?}: {source}"))]
115    SignalFailed {
116        /// Signal the executor tried to deliver.
117        signal: Signal,
118        /// Process id of the target, when available.
119        pid: Option<ProcessId>,
120        /// Underlying I/O error.
121        source: std::io::Error,
122    },
123
124    /// Reaping the child failed.
125    #[snafu(display("failed to wait for pid {pid:?}: {source}"))]
126    WaitFailed {
127        /// Process id of the target, when available.
128        pid: Option<ProcessId>,
129        /// Underlying I/O error.
130        source: std::io::Error,
131    },
132}
133
134/// Handle to one spawned child process.
135///
136/// Implementors expose the lifecycle operations the executor needs
137/// after `spawn`: identity ([`Self::id`]), signal delivery
138/// ([`Self::send_signal`]), and reaping ([`Self::wait`]).
139///
140/// stdout and stderr are NOT exposed through this trait. The
141/// [`ProcessSpawner::spawn`] call extracts them and returns them as
142/// fields on a [`Spawned`] value, which is the structural enforcement
143/// of the take-once invariant on those streams.
144pub trait Process {
145    /// Concrete async byte-stream type the implementation uses for
146    /// the child's stdout. Both [`StdProcess`](crate::std_impl::StdProcess)
147    /// and the mock satisfy [`AsyncRead`] + [`Send`] + [`Unpin`].
148    type Stdout: AsyncRead + Send + Unpin;
149    /// Concrete async byte-stream type the implementation uses for
150    /// the child's stderr.
151    type Stderr: AsyncRead + Send + Unpin;
152
153    /// Numeric process id, or [`None`] once the child has been
154    /// reaped (after [`Self::wait`] resolves).
155    fn id(&self) -> Option<ProcessId>;
156
157    /// Deliver `signal` to the child. Best-effort: returns as soon as
158    /// the signal is queued by the kernel; does NOT wait for the
159    /// child to acknowledge or terminate.
160    ///
161    /// On Unix, the std backend targets the child's process group
162    /// (`kill(-pgid, sig)`) so any subprocesses the task itself
163    /// forked also receive the signal (`EXEC-013` step 2,
164    /// `EXEC-014`). On non-Unix hosts, only [`Signal::Kill`] is
165    /// implemented; [`Signal::Terminate`] and [`Signal::Interrupt`]
166    /// surface [`std::io::ErrorKind::Unsupported`].
167    ///
168    /// # Errors
169    ///
170    /// Returns [`ProcessError::SignalFailed`] when the OS rejects the
171    /// signal (e.g. the child has already been reaped) or when the
172    /// implementation does not support the requested variant on the
173    /// host platform.
174    fn send_signal(&mut self, signal: Signal) -> Result<(), ProcessError>;
175
176    /// Wait for the child to terminate and yield its exit status.
177    ///
178    /// # Errors
179    ///
180    /// Returns [`ProcessError::WaitFailed`] when reaping fails.
181    fn wait(&mut self) -> impl Future<Output = Result<ExitStatus, ProcessError>> + Send;
182}
183
184/// Triple of values produced by [`ProcessSpawner::spawn`].
185///
186/// The owned-at-spawn shape: stdout and stderr are taken out of the
187/// child once at spawn time and exposed as fields. Callers move them
188/// into reader tasks while keeping [`Self::process`] for waiting and
189/// signalling.
190pub struct Spawned<P: Process> {
191    /// Handle to the spawned child for waiting and signalling.
192    pub process: P,
193    /// Async byte stream of the child's stdout.
194    pub stdout: P::Stdout,
195    /// Async byte stream of the child's stderr.
196    pub stderr: P::Stderr,
197}
198
199/// Trait for spawning child processes.
200///
201/// The production backend
202/// [`StdProcessSpawner`](crate::std_impl::StdProcessSpawner) wraps
203/// [`tokio::process::Command`]; a scriptable test-double mock lives
204/// alongside under [`crate::mock_impl`] but is gated to test builds
205/// only.
206///
207/// Consumers borrow a `ProcessSpawner` implementation generically
208/// rather than depending on either concrete impl, which keeps test
209/// code free of the production tokio command wiring while remaining
210/// on the same tokio runtime.
211pub trait ProcessSpawner {
212    /// The implementation's concrete [`Process`] type.
213    type Process: Process;
214
215    /// Spawn a child process per `plan`.
216    ///
217    /// On success, both stdout and stderr are configured as pipes and
218    /// returned in the [`Spawned`] value's fields; stdin is closed.
219    /// The returned future is [`Send`] so the executor can spawn it
220    /// onto a multi-threaded runtime.
221    ///
222    /// # Errors
223    ///
224    /// Returns [`ProcessError::NonAbsoluteCwd`] when `plan.cwd` is
225    /// relative, and [`ProcessError::SpawnFailed`] when the OS
226    /// refuses to launch the child.
227    fn spawn(
228        &self,
229        plan: &SpawnPlan,
230    ) -> impl Future<Output = Result<Spawned<Self::Process>, ProcessError>> + Send;
231}