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}