prodigy/subprocess/streaming/
runner.rs1use super::processor::StreamProcessor;
4use super::types::{StreamSource, StreamingOutput};
5use crate::subprocess::{ProcessCommand, ProcessError, ProcessRunner};
6use anyhow::{Context, Result};
7use async_trait::async_trait;
8use std::process::Stdio;
9use std::time::Instant;
10use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
11use tokio::process::Command;
12
13pub struct StreamingCommandRunner {
15 inner: Box<dyn ProcessRunner>,
16}
17
18impl StreamingCommandRunner {
19 pub fn new(inner: Box<dyn ProcessRunner>) -> Self {
21 Self { inner }
22 }
23
24 pub async fn run_streaming(
26 &self,
27 command: ProcessCommand,
28 processors: Vec<Box<dyn StreamProcessor>>,
29 ) -> Result<StreamingOutput> {
30 let start = Instant::now();
31
32 let mut cmd = Command::new(&command.program);
34 cmd.args(&command.args);
35
36 for (key, value) in &command.env {
38 cmd.env(key, value);
39 }
40
41 if let Some(dir) = &command.working_dir {
43 cmd.current_dir(dir);
44 }
45
46 cmd.stdin(Stdio::piped());
48 cmd.stdout(Stdio::piped());
49 if command.suppress_stderr {
50 cmd.stderr(Stdio::null());
51 } else {
52 cmd.stderr(Stdio::piped());
53 }
54
55 let mut child = cmd.spawn().context("Failed to spawn process")?;
57
58 if let Some(stdin_data) = &command.stdin {
60 if let Some(mut stdin) = child.stdin.take() {
61 use tokio::io::AsyncWriteExt;
62 stdin
63 .write_all(stdin_data.as_bytes())
64 .await
65 .context("Failed to write to stdin")?;
66 stdin.flush().await.context("Failed to flush stdin")?;
67 }
68 }
69
70 let stdout = child
72 .stdout
73 .take()
74 .ok_or_else(|| anyhow::anyhow!("Failed to capture stdout"))?;
75 let stderr = child
76 .stderr
77 .take()
78 .ok_or_else(|| anyhow::anyhow!("Failed to capture stderr"))?;
79
80 let processors = std::sync::Arc::new(processors);
82
83 let stdout_processors = processors.clone();
85 let stderr_processors = processors.clone();
86
87 let stdout_handle = tokio::spawn(async move {
88 process_stream(stdout, StreamSource::Stdout, &stdout_processors).await
89 });
90
91 let stderr_handle = tokio::spawn(async move {
92 process_stream(stderr, StreamSource::Stderr, &stderr_processors).await
93 });
94
95 let status = if let Some(timeout_duration) = command.timeout {
97 match tokio::time::timeout(timeout_duration, child.wait()).await {
98 Ok(Ok(status)) => status,
99 Ok(Err(e)) => {
100 let error = anyhow::Error::new(e);
102 for processor in processors.iter() {
103 let _ = processor.on_error(&error).await;
104 }
105 return Err(error);
106 }
107 Err(_) => {
108 child
110 .kill()
111 .await
112 .context("Failed to kill timed out process")?;
113 let timeout_err =
114 anyhow::anyhow!("Process timed out after {:?}", timeout_duration);
115 for processor in processors.iter() {
116 let _ = processor.on_error(&timeout_err).await;
117 }
118 return Err(timeout_err);
119 }
120 }
121 } else {
122 child.wait().await.context("Failed to wait for process")?
123 };
124
125 let (stdout_lines, stderr_lines) = tokio::try_join!(stdout_handle, stderr_handle)?;
127 let stdout_lines = stdout_lines?;
128 let stderr_lines = stderr_lines?;
129
130 let exit_code = status.code();
132 for processor in processors.iter() {
133 processor.on_complete(exit_code).await?;
134 }
135
136 Ok(StreamingOutput {
137 status,
138 stdout: stdout_lines,
139 stderr: stderr_lines,
140 duration: start.elapsed(),
141 })
142 }
143
144 pub async fn run_batch(
146 &self,
147 command: ProcessCommand,
148 ) -> Result<crate::subprocess::ProcessOutput> {
149 self.inner
150 .run(command)
151 .await
152 .map_err(|e| anyhow::anyhow!("Process execution failed: {}", e))
153 }
154}
155
156async fn process_stream(
158 stream: impl AsyncRead + Unpin,
159 source: StreamSource,
160 processors: &[Box<dyn StreamProcessor>],
161) -> Result<Vec<String>> {
162 let reader = BufReader::new(stream);
163 let mut lines_reader = reader.lines();
164 let mut output = Vec::new();
165
166 while let Ok(Some(line)) = lines_reader.next_line().await {
167 output.push(line.clone());
169
170 for processor in processors {
172 if let Err(e) = processor.process_line(&line, source).await {
173 tracing::warn!("Processor failed to handle line from {:?}: {}", source, e);
174 }
176 }
177 }
178
179 Ok(output)
180}
181
182pub struct StreamingProcessRunner;
184
185impl Default for StreamingProcessRunner {
186 fn default() -> Self {
187 Self::new()
188 }
189}
190
191impl StreamingProcessRunner {
192 pub fn new() -> Self {
194 Self
195 }
196}
197
198#[async_trait]
199impl ProcessRunner for StreamingProcessRunner {
200 async fn run(
201 &self,
202 command: ProcessCommand,
203 ) -> Result<crate::subprocess::ProcessOutput, ProcessError> {
204 let runner =
206 StreamingCommandRunner::new(Box::new(crate::subprocess::runner::TokioProcessRunner));
207
208 let processors: Vec<Box<dyn StreamProcessor>> = vec![];
210 let result = runner
211 .run_streaming(command, processors)
212 .await
213 .map_err(|e| ProcessError::Io(std::io::Error::other(e.to_string())))?;
214
215 Ok(crate::subprocess::ProcessOutput {
217 status: if result.status.success() {
218 crate::subprocess::runner::ExitStatus::Success
219 } else {
220 crate::subprocess::runner::ExitStatus::Error(result.status.code().unwrap_or(-1))
221 },
222 stdout: result.stdout.join("\n"),
223 stderr: result.stderr.join("\n"),
224 duration: result.duration,
225 })
226 }
227
228 async fn run_streaming(
229 &self,
230 command: ProcessCommand,
231 ) -> Result<crate::subprocess::ProcessStream, ProcessError> {
232 let runner = crate::subprocess::runner::TokioProcessRunner;
235 runner.run_streaming(command).await
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::subprocess::streaming::processor::LoggingProcessor;
243 use std::time::Duration;
244
245 #[tokio::test]
246 async fn test_streaming_echo() {
247 let runner =
248 StreamingCommandRunner::new(Box::new(crate::subprocess::runner::TokioProcessRunner));
249
250 let processors: Vec<Box<dyn StreamProcessor>> =
251 vec![Box::new(LoggingProcessor::new("test"))];
252
253 let command = ProcessCommand {
254 program: "echo".to_string(),
255 args: vec!["hello world".to_string()],
256 env: Default::default(),
257 working_dir: None,
258 timeout: None,
259 stdin: None,
260 suppress_stderr: false,
261 };
262
263 let result = runner.run_streaming(command, processors).await.unwrap();
264 assert!(result.status.success());
265 assert!(!result.stdout.is_empty());
266 assert_eq!(result.stdout[0], "hello world");
267 }
268
269 #[tokio::test]
270 async fn test_streaming_with_timeout() {
271 let runner =
272 StreamingCommandRunner::new(Box::new(crate::subprocess::runner::TokioProcessRunner));
273
274 let processors: Vec<Box<dyn StreamProcessor>> = vec![];
275
276 let command = ProcessCommand {
277 program: "sleep".to_string(),
278 args: vec!["10".to_string()],
279 env: Default::default(),
280 working_dir: None,
281 timeout: Some(Duration::from_millis(100)),
282 stdin: None,
283 suppress_stderr: false,
284 };
285
286 let result = runner.run_streaming(command, processors).await;
287 assert!(result.is_err());
288 assert!(result.unwrap_err().to_string().contains("timed out"));
289 }
290}