Skip to main content

prodigy/subprocess/streaming/
runner.rs

1//! Streaming command runner implementation
2
3use 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
13/// Command runner with streaming support
14pub struct StreamingCommandRunner {
15    inner: Box<dyn ProcessRunner>,
16}
17
18impl StreamingCommandRunner {
19    /// Create a new streaming command runner
20    pub fn new(inner: Box<dyn ProcessRunner>) -> Self {
21        Self { inner }
22    }
23
24    /// Run a command with streaming output processing
25    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        // Build the tokio command
33        let mut cmd = Command::new(&command.program);
34        cmd.args(&command.args);
35
36        // Set environment variables
37        for (key, value) in &command.env {
38            cmd.env(key, value);
39        }
40
41        // Set working directory
42        if let Some(dir) = &command.working_dir {
43            cmd.current_dir(dir);
44        }
45
46        // Configure stdio for streaming
47        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        // Spawn the process
56        let mut child = cmd.spawn().context("Failed to spawn process")?;
57
58        // Handle stdin if provided
59        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        // Take ownership of output streams
71        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        // Create shared processor references
81        let processors = std::sync::Arc::new(processors);
82
83        // Process streams in parallel
84        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        // Apply timeout if specified
96        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                    // Process wait error
101                    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                    // Timeout occurred
109                    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        // Wait for stream processing to complete
126        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        // Notify processors of completion
131        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    /// Run a command without streaming (fallback to batch mode)
145    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
156/// Process a stream line by line
157async 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        // Store for final output
168        output.push(line.clone());
169
170        // Process through all handlers
171        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                // Continue with other processors even if one fails
175            }
176        }
177    }
178
179    Ok(output)
180}
181
182/// Streaming runner that implements ProcessRunner trait
183pub struct StreamingProcessRunner;
184
185impl Default for StreamingProcessRunner {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191impl StreamingProcessRunner {
192    /// Create a new streaming process runner
193    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        // Create a streaming runner with the default process runner
205        let runner =
206            StreamingCommandRunner::new(Box::new(crate::subprocess::runner::TokioProcessRunner));
207
208        // Run with streaming with empty processors for now
209        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        // Convert to ProcessOutput
216        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        // This is already a streaming runner, delegate to inner implementation
233        // For now, we'll use the default implementation
234        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}