asimov_runner/executor.rs
1// This is free and unencumbered software released into the public domain.
2
3//! Low-level Tokio child-process execution with ASIMOV executable lookup.
4//!
5//! [`Executor`] configures a command without starting it. Call
6//! [`Executor::execute`] for a complete spawn-and-wait cycle, or use
7//! [`Executor::spawn`] and [`Executor::wait`] to interact with the child between
8//! those steps. `execute` and `wait` buffer captured output until the child exits;
9//! `spawn` returns the live child handle without collecting its output.
10//! [`Executor::execute_jsonl`] and [`Executor::execute_jsonl_with_input`] instead
11//! return live batch streams with concurrent input/output handling.
12
13use crate::{
14 Command, ExecutionCompletion, ExecutorError, ExecutorResult, Input, InputCompletion, Output,
15};
16use alloc::borrow::ToOwned;
17use std::{ffi::OsStr, io::ErrorKind, process::Stdio};
18use tokio::process::Child;
19
20/// A configured command that reports process failures as [`ExecutorError`].
21///
22/// Executables are looked up in the ASIMOV root's `libexec` directory before
23/// falling back to the supplied program name or path. By default, all three
24/// standard streams are connected to the null device, `NO_COLOR=1` is set, and
25/// spawned children are configured to be killed when their handles are dropped.
26/// Use [`command`](Self::command) to customize these settings before execution.
27///
28/// Each execution spawns a new process using the stored command configuration.
29/// Stdout is returned only when configured as a pipe; otherwise the successful
30/// result is an empty cursor or stream. Captured stderr is included in process
31/// exit errors when it is valid UTF-8. [`ExecutionCompletion`] exposes exit status,
32/// input delivery, and diagnostics separately; its `into_result` method defines
33/// the error precedence used by the convenience APIs.
34///
35/// # Example
36///
37/// Prepare a piped input and capture both output streams before spawning:
38///
39/// ```no_run
40/// use asimov_runner::{Executor, Input};
41/// use std::io::Cursor;
42///
43/// # async fn example() -> Result<(), asimov_runner::ExecutorError> {
44/// let mut input = Input::AsyncRead(Box::new(Cursor::new(b"example input".to_vec())));
45/// let mut executor = Executor::new("asimov-example-reader");
46/// executor.command().stdin(input.as_stdio());
47/// executor.capture_stdout();
48/// executor.capture_stderr();
49///
50/// let bytes = executor.execute_with_input(&mut input).await?.into_inner();
51/// # Ok(())
52/// # }
53/// ```
54#[derive(Debug)]
55pub struct Executor(Command, crate::BatchOptions);
56
57impl Executor {
58 /// Prepares a command with the executor's default environment and streams.
59 ///
60 /// If `<asimov_root>/libexec/<program>` exists, it is selected; otherwise
61 /// `program` is passed to Tokio unchanged, allowing normal `PATH` lookup for
62 /// a bare name. Existence does not guarantee that the selected file can be
63 /// executed. Such errors are reported when the command is spawned.
64 pub fn new(program: impl AsRef<OsStr>) -> Self {
65 let libexec_path = asimov_env::paths::asimov_root()
66 .join("libexec")
67 .join(program.as_ref());
68
69 let mut command = if libexec_path.exists() {
70 Command::new(libexec_path)
71 } else {
72 Command::new(program)
73 };
74
75 command.env("NO_COLOR", "1"); // See: https://no-color.org
76 command.stdin(Stdio::null());
77 command.stdout(Stdio::null());
78 command.stderr(Stdio::null());
79 command.kill_on_drop(true);
80 Self(command, crate::BatchOptions::default())
81 }
82
83 /// Configures batching for captured JSONL results, without changing the
84 /// subprocess command or byte-oriented execution methods.
85 #[must_use]
86 pub fn with_batching(mut self, options: crate::BatchOptions) -> Self {
87 self.1 = options;
88 self
89 }
90
91 /// The policy used for captured JSONL batches.
92 pub fn batch_options(&self) -> crate::BatchOptions {
93 self.1
94 }
95
96 /// Returns the underlying command for configuring arguments, environment,
97 /// working directory, standard streams, or process-lifetime behavior.
98 pub fn command(&mut self) -> &mut Command {
99 &mut self.0
100 }
101
102 /// Connects the child's stdin to the null device, giving it immediate EOF.
103 pub fn ignore_stdin(&mut self) {
104 self.0.stdin(Stdio::null());
105 }
106
107 /// Discards the child's stdout by connecting it to the null device.
108 pub fn ignore_stdout(&mut self) {
109 self.0.stdout(Stdio::null());
110 }
111
112 /// Discards the child's stderr, leaving no diagnostic text to capture.
113 pub fn ignore_stderr(&mut self) {
114 self.0.stderr(Stdio::null());
115 }
116
117 /// Pipes stdout for buffered execution or live JSONL batch streaming.
118 pub fn capture_stdout(&mut self) {
119 self.0.stdout(Stdio::piped());
120 }
121
122 /// Pipes stderr so execution can attach its UTF-8 contents to exit errors.
123 pub fn capture_stderr(&mut self) {
124 self.0.stderr(Stdio::piped());
125 }
126
127 /// Spawns the command and waits for completion, returning captured stdout.
128 ///
129 /// No input is written by this method. Use
130 /// [`execute_with_input`](Self::execute_with_input) to copy an input stream
131 /// into the child.
132 ///
133 /// # Errors
134 ///
135 /// Returns an error if spawning or waiting fails, or if the child exits
136 /// unsuccessfully. See [`ExecutorError`] for the error categories.
137 pub async fn execute(&mut self) -> ExecutorResult {
138 let process = self.spawn().await?;
139 self.wait(process).await
140 }
141
142 /// Spawns the command, copies `input` into stdin, and waits for completion.
143 ///
144 /// Configure stdin with `input.as_stdio()` through [`command`](Self::command)
145 /// before calling this method. [`Input::Ignored`] performs no copy and does
146 /// not change the command's stdin configuration. An asynchronous reader is
147 /// normally consumed from its current position to EOF, then the stdin pipe
148 /// is closed. Reusing the input does not rewind it or restore partly written
149 /// bytes after early exit or cancellation.
150 ///
151 /// Input is fed concurrently with draining stdout and stderr. JSONL input
152 /// is coalesced per batch with backpressure and source error propagation.
153 /// Early child exit cancels a pending input feed and is reported as
154 /// [`ExecutorError::IncompleteInput`] on an otherwise successful exit. Use
155 /// [`execute_with_io_completion`](Self::execute_with_io_completion) to inspect
156 /// intentional early exit. Success confirms EOF and delivery to the pipe,
157 /// not application-level processing of the supplied data.
158 ///
159 /// # Errors
160 ///
161 /// Returns an error if spawning, copying input, or waiting fails, or if the
162 /// child exits unsuccessfully. Non-ignored input requires piped stdin;
163 /// otherwise feeding it returns an I/O `InvalidInput` error.
164 pub async fn execute_with_input(&mut self, input: &mut Input) -> ExecutorResult {
165 self.execute_with_io(input, &mut Output::Captured).await
166 }
167
168 /// Feeds input and routes stdout concurrently, requiring complete delivery
169 /// and a successful exit. Configure stdin/stdout from `input.as_stdio()` and
170 /// `output.as_stdio()` first. Only [`Output::Captured`] returns payload bytes.
171 ///
172 /// # Errors
173 ///
174 /// Returns spawn, transport, forwarding, input-delivery, or exit errors using
175 /// the precedence documented by [`ExecutionCompletion::into_result`].
176 pub async fn execute_with_io(
177 &mut self,
178 input: &mut Input,
179 output: &mut Output,
180 ) -> ExecutorResult {
181 self.execute_with_io_completion(input, output)
182 .await?
183 .into_result()
184 }
185
186 /// Executes with explicit exit and input-delivery outcomes.
187 ///
188 /// Configure stdin/stdout from the supplied input/output policies before
189 /// calling. Input and writers are borrowed and retained for reuse, without
190 /// rewinding. Stdout forwarding and stderr collection run concurrently with
191 /// feeding stdin and waiting for exit. Early exit cancels pending input;
192 /// source errors terminate the child, while broken pipes allow it to finish
193 /// naturally so its exit diagnostics are preserved. Ready input outcomes are
194 /// polled before exit to give EOF and source errors consistent precedence.
195 ///
196 /// # Errors
197 ///
198 /// Only spawn, stdout/stderr transport, writer, and wait failures are returned
199 /// directly. Child exit and input failures are fields of the returned
200 /// [`ExecutionCompletion`], even when the exit status is unsuccessful.
201 pub async fn execute_with_io_completion(
202 &mut self,
203 input: &mut Input,
204 output: &mut Output,
205 ) -> Result<ExecutionCompletion, ExecutorError> {
206 communicate(self.spawn().await?, input, output).await
207 }
208
209 /// Starts a new child process using the current command configuration.
210 ///
211 /// The returned handle provides access to any piped standard streams. Unless
212 /// overridden through [`command`](Self::command), dropping that handle before
213 /// completion requests termination of that child, without waiting for it to
214 /// be reaped or guaranteeing termination of its descendants.
215 ///
216 /// # Errors
217 ///
218 /// Maps an I/O `NotFound` error to [`ExecutorError::MissingProgram`] and all
219 /// other spawn errors to [`ExecutorError::SpawnFailure`].
220 pub async fn spawn(&mut self) -> Result<Child, ExecutorError> {
221 match self.0.spawn() {
222 Ok(process) => Ok(process),
223 Err(err) if err.kind() == ErrorKind::NotFound => {
224 let program = self.0.as_std().get_program().to_owned();
225 return Err(ExecutorError::MissingProgram(program));
226 },
227 Err(err) => return Err(ExecutorError::SpawnFailure(err)),
228 }
229 }
230
231 /// Collects the child's remaining piped output and waits for it to exit.
232 ///
233 /// Any stdin handle still owned by `process` is closed before waiting.
234 /// On success, returns all captured stdout in a cursor positioned at zero.
235 /// Streams whose handles were taken from the child are not collected here.
236 ///
237 /// # Errors
238 ///
239 /// Returns [`ExecutorError::UnexpectedOther`] for I/O errors. An unsuccessful
240 /// exit becomes [`ExecutorError::Failure`] for a recognized sysexits status
241 /// or [`ExecutorError::UnexpectedFailure`] otherwise, with captured UTF-8
242 /// stderr attached. Stdout is not retained in either failure variant.
243 pub async fn wait(&mut self, process: Child) -> ExecutorResult {
244 communicate(process, &mut Input::Ignored, &mut Output::Captured)
245 .await?
246 .into_result()
247 }
248}
249
250/// Supervises input and process exit independently from draining stdout/stderr.
251pub(crate) async fn communicate(
252 mut process: Child,
253 input: &mut Input,
254 output: &mut Output,
255) -> Result<ExecutionCompletion, ExecutorError> {
256 communicate_child(&mut process, input, output).await
257}
258
259/// Borrows the child so a pipeline supervisor can cancel I/O, then kill and reap
260/// the same child. The owning caller retains kill-on-drop cancellation behavior.
261pub(crate) async fn communicate_child(
262 process: &mut Child,
263 input: &mut Input,
264 output: &mut Output,
265) -> Result<ExecutionCompletion, ExecutorError> {
266 use crate::completion::InputFailure;
267 use alloc::vec::Vec;
268 use tokio::io::AsyncReadExt;
269
270 let stdin = process.stdin.take();
271 let stdout = process.stdout.take();
272 let stderr = process.stderr.take();
273 let supervise = async {
274 let feed = input.write_to(stdin);
275 tokio::pin!(feed);
276 let input = tokio::select! {
277 biased;
278 result = &mut feed => match result {
279 Ok(()) => InputCompletion::Complete,
280 Err(InputFailure::Source(error)) => {
281 process.start_kill()?;
282 InputCompletion::SourceFailed(error)
283 },
284 Err(InputFailure::Write(error)) => {
285 if error.kind() != ErrorKind::BrokenPipe {
286 process.start_kill()?;
287 }
288 InputCompletion::WriteFailed(error)
289 },
290 },
291 status = process.wait() => {
292 return Ok::<_, std::io::Error>((status?, InputCompletion::Interrupted));
293 },
294 };
295 Ok((process.wait().await?, input))
296 };
297 let read_stderr = async {
298 let mut bytes = Vec::new();
299 if let Some(mut stderr) = stderr {
300 stderr.read_to_end(&mut bytes).await?;
301 }
302 Ok::<_, std::io::Error>(bytes)
303 };
304 let result = tokio::try_join!(supervise, output.read_from(stdout), read_stderr);
305 match result {
306 Ok(((status, input), stdout, stderr)) => {
307 #[cfg(feature = "tracing")]
308 tracing::trace!("The command exited with: {}", status);
309 Ok(ExecutionCompletion {
310 output: std::process::Output {
311 status,
312 stdout,
313 stderr,
314 },
315 input,
316 })
317 },
318 Err(error) => {
319 // A failed destination must not leave the producer blocked on its pipes.
320 let _ = process.start_kill();
321 let _ = process.wait().await;
322 Err(error.into())
323 },
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[tokio::test]
332 async fn test_success() {
333 let mut runner = Executor::new("curl");
334 runner.command().arg("https://www.google.com");
335 let result = runner.execute().await;
336 assert!(result.is_ok());
337 }
338
339 #[tokio::test]
340 async fn test_missing_program() {
341 let mut runner = Executor::new("this-command-does-not-exist");
342 let result = runner.execute().await;
343 assert!(matches!(result, Err(ExecutorError::MissingProgram(_))));
344 }
345
346 #[cfg(unix)]
347 #[tokio::test]
348 async fn test_spawn_failure() {
349 let mut runner = Executor::new("/dev/null");
350 let result = runner.execute().await;
351 assert!(matches!(result, Err(ExecutorError::SpawnFailure(_))));
352 }
353
354 #[tokio::test]
355 async fn test_unexpected_failure() {
356 let mut runner = Executor::new("curl");
357 let result = runner.execute().await;
358 assert!(matches!(
359 result,
360 Err(ExecutorError::UnexpectedFailure(_, _))
361 ));
362 }
363}