Skip to main content

vtcode_bash_runner/
pipe.rs

1//! Async pipe-based process spawning with unified handle interface.
2//!
3//! This module provides helpers for spawning non-interactive processes using
4//! regular pipes for stdin/stdout/stderr, with proper process group management
5//! for reliable cleanup.
6//!
7//! Inspired by codex-rs/utils/pty pipe spawning patterns.
8
9use hashbrown::HashMap;
10use std::io::{self, ErrorKind};
11use std::path::Path;
12use std::process::Stdio;
13use std::sync::Arc;
14use std::sync::Mutex as StdMutex;
15use std::sync::atomic::AtomicBool;
16
17use anyhow::{Context, Result};
18use bytes::Bytes;
19use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader};
20use tokio::process::Command;
21use tokio::sync::{broadcast, mpsc, oneshot};
22use tokio::task::JoinHandle;
23
24use crate::process::{ChildTerminator, ProcessHandle, SpawnedProcess};
25use crate::process_group;
26
27/// Terminator for pipe-based child processes.
28struct PipeChildTerminator {
29    #[cfg(windows)]
30    pid: u32,
31    #[cfg(unix)]
32    process_group_id: u32,
33}
34
35impl ChildTerminator for PipeChildTerminator {
36    fn kill(&mut self) -> io::Result<()> {
37        #[cfg(unix)]
38        {
39            process_group::kill_process_group(self.process_group_id)
40        }
41
42        #[cfg(windows)]
43        {
44            process_group::kill_process(self.pid)
45        }
46
47        #[cfg(not(any(unix, windows)))]
48        {
49            Ok(())
50        }
51    }
52}
53
54/// Read from an async reader and send chunks to a broadcast channel.
55async fn read_output_stream<R>(mut reader: R, output_tx: broadcast::Sender<Bytes>)
56where
57    R: AsyncRead + Unpin,
58{
59    let mut buf = vec![0u8; 8_192];
60    loop {
61        match reader.read(&mut buf).await {
62            Ok(0) => break,
63            Ok(n) => {
64                let _ = output_tx.send(Bytes::copy_from_slice(&buf[..n]));
65            }
66            Err(ref e) if e.kind() == ErrorKind::Interrupted => continue,
67            Err(_) => break,
68        }
69    }
70}
71
72/// Stdin mode for pipe-based processes.
73#[derive(Clone, Copy)]
74pub enum PipeStdinMode {
75    /// Stdin is available as a pipe.
76    Piped,
77    /// Stdin is connected to /dev/null (immediate EOF).
78    Null,
79}
80
81/// Options for spawning a pipe-based process.
82#[derive(Clone)]
83pub struct PipeSpawnOptions {
84    /// The program to execute.
85    pub program: String,
86    /// Arguments to pass to the program.
87    pub args: Vec<String>,
88    /// Working directory for the process.
89    pub cwd: std::path::PathBuf,
90    /// Environment variables (if None, inherits from parent).
91    pub env: Option<HashMap<String, String>>,
92    /// Override for `argv[0]` (Unix only).
93    pub arg0: Option<String>,
94    /// Stdin mode.
95    pub stdin_mode: PipeStdinMode,
96}
97
98impl PipeSpawnOptions {
99    /// Create new spawn options with default settings.
100    pub fn new(program: impl Into<String>, cwd: impl Into<std::path::PathBuf>) -> Self {
101        Self {
102            program: program.into(),
103            args: Vec::new(),
104            cwd: cwd.into(),
105            env: None,
106            arg0: None,
107            stdin_mode: PipeStdinMode::Piped,
108        }
109    }
110
111    /// Add arguments.
112    pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
113        self.args = args.into_iter().map(Into::into).collect();
114        self
115    }
116
117    /// Set environment variables.
118    pub fn env(mut self, env: HashMap<String, String>) -> Self {
119        self.env = Some(env);
120        self
121    }
122
123    /// Set arg0 override (Unix only).
124    pub fn arg0(mut self, arg0: impl Into<String>) -> Self {
125        self.arg0 = Some(arg0.into());
126        self
127    }
128
129    /// Set stdin mode.
130    pub fn stdin_mode(mut self, mode: PipeStdinMode) -> Self {
131        self.stdin_mode = mode;
132        self
133    }
134}
135
136/// Spawn a process using regular pipes, with configurable options.
137async fn spawn_process_internal(opts: PipeSpawnOptions) -> Result<SpawnedProcess> {
138    if opts.program.is_empty() {
139        anyhow::bail!("missing program for pipe spawn");
140    }
141
142    let mut command = Command::new(&opts.program);
143
144    #[cfg(unix)]
145    if let Some(ref arg0) = opts.arg0 {
146        command.arg0(arg0);
147    }
148
149    #[cfg(unix)]
150    #[expect(
151        unsafe_code,
152        reason = "detach_from_tty only calls setpgrp/setpgid to detach the child process from the controlling terminal; it is a pure process-group operation with no undefined behavior"
153    )]
154    // SAFETY:  only invokes /,
155    // which is a pure process-group operation with no undefined behavior.
156    unsafe {
157        command.pre_exec(process_group::detach_from_tty);
158    }
159
160    #[cfg(not(unix))]
161    let _ = &opts.arg0;
162
163    command.current_dir(&opts.cwd);
164
165    // Handle environment
166    if let Some(ref env) = opts.env {
167        command.env_clear();
168        for (key, value) in env {
169            command.env(key, value);
170        }
171    }
172
173    for arg in &opts.args {
174        command.arg(arg);
175    }
176
177    match opts.stdin_mode {
178        PipeStdinMode::Piped => {
179            command.stdin(Stdio::piped());
180        }
181        PipeStdinMode::Null => {
182            command.stdin(Stdio::null());
183        }
184    }
185    command.stdout(Stdio::piped());
186    command.stderr(Stdio::piped());
187
188    let mut child = command.spawn().context("failed to spawn pipe process")?;
189    let pid = child.id().ok_or_else(|| io::Error::other("missing child pid"))?;
190
191    #[cfg(unix)]
192    let process_group_id = pid;
193
194    let stdin = child.stdin.take();
195    let stdout = child.stdout.take();
196    let stderr = child.stderr.take();
197
198    let (writer_tx, mut writer_rx) = mpsc::channel::<Vec<u8>>(128);
199    let (output_tx, _) = broadcast::channel::<Bytes>(256);
200    let initial_output_rx = output_tx.subscribe();
201
202    // Spawn writer task
203    let writer_handle = if let Some(stdin) = stdin {
204        let writer = Arc::new(tokio::sync::Mutex::new(stdin));
205        tokio::spawn(async move {
206            while let Some(bytes) = writer_rx.recv().await {
207                let mut guard = writer.lock().await;
208                let _ = guard.write_all(&bytes).await;
209                let _ = guard.flush().await;
210            }
211        })
212    } else {
213        drop(writer_rx);
214        tokio::spawn(async {})
215    };
216
217    // Spawn reader tasks for stdout and stderr
218    let stdout_handle = stdout.map(|stdout| {
219        let output_tx = output_tx.clone();
220        tokio::spawn(async move {
221            read_output_stream(BufReader::new(stdout), output_tx).await;
222        })
223    });
224
225    let stderr_handle = stderr.map(|stderr| {
226        let output_tx = output_tx.clone();
227        tokio::spawn(async move {
228            read_output_stream(BufReader::new(stderr), output_tx).await;
229        })
230    });
231
232    let mut reader_abort_handles = Vec::new();
233    if let Some(ref handle) = stdout_handle {
234        reader_abort_handles.push(handle.abort_handle());
235    }
236    if let Some(ref handle) = stderr_handle {
237        reader_abort_handles.push(handle.abort_handle());
238    }
239
240    let reader_handle = tokio::spawn(async move {
241        if let Some(handle) = stdout_handle {
242            let _ = handle.await;
243        }
244        if let Some(handle) = stderr_handle {
245            let _ = handle.await;
246        }
247    });
248
249    // Spawn wait task
250    let (exit_tx, exit_rx) = oneshot::channel::<i32>();
251    let exit_status = Arc::new(AtomicBool::new(false));
252    let wait_exit_status = Arc::clone(&exit_status);
253    let exit_code = Arc::new(StdMutex::new(None));
254    let wait_exit_code = Arc::clone(&exit_code);
255
256    let wait_handle: JoinHandle<()> = tokio::spawn(async move {
257        let code = match child.wait().await {
258            Ok(status) => status.code().unwrap_or(-1),
259            Err(_) => -1,
260        };
261        wait_exit_status.store(true, std::sync::atomic::Ordering::SeqCst);
262        if let Ok(mut guard) = wait_exit_code.lock() {
263            *guard = Some(code);
264        }
265        let _ = exit_tx.send(code);
266    });
267
268    let (handle, output_rx) = ProcessHandle::new(
269        writer_tx,
270        output_tx,
271        initial_output_rx,
272        Box::new(PipeChildTerminator {
273            #[cfg(windows)]
274            pid,
275            #[cfg(unix)]
276            process_group_id,
277        }),
278        reader_handle,
279        reader_abort_handles,
280        writer_handle,
281        wait_handle,
282        exit_status,
283        exit_code,
284        None,
285    );
286
287    Ok(SpawnedProcess { session: handle, output_rx, exit_rx })
288}
289
290/// Spawn a process using regular pipes (no PTY), returning handles for stdin, output, and exit.
291///
292/// # Example
293/// ```ignore
294/// use vtcode_bash_runner::pipe::spawn_process;
295/// use hashbrown::HashMap;
296/// use std::path::Path;
297///
298/// let env: HashMap<String, String> = std::env::vars().collect();
299/// let spawned = spawn_process("echo", &["hello".into()], Path::new("."), &env, &None).await?;
300/// let output_rx = spawned.output_rx;
301/// let exit_code = spawned.exit_rx.await?;
302/// ```
303pub async fn spawn_process(
304    program: &str,
305    args: &[String],
306    cwd: &Path,
307    env: &HashMap<String, String>,
308    arg0: &Option<String>,
309) -> Result<SpawnedProcess> {
310    let opts = PipeSpawnOptions {
311        program: program.to_string(),
312        args: args.to_vec(),
313        cwd: cwd.to_path_buf(),
314        env: Some(env.clone()),
315        arg0: arg0.clone(),
316        stdin_mode: PipeStdinMode::Piped,
317    };
318    spawn_process_internal(opts).await
319}
320
321/// Spawn a process using regular pipes, but close stdin immediately.
322///
323/// This is useful for commands that should see EOF on stdin immediately.
324pub async fn spawn_process_no_stdin(
325    program: &str,
326    args: &[String],
327    cwd: &Path,
328    env: &HashMap<String, String>,
329    arg0: &Option<String>,
330) -> Result<SpawnedProcess> {
331    let opts = PipeSpawnOptions {
332        program: program.to_string(),
333        args: args.to_vec(),
334        cwd: cwd.to_path_buf(),
335        env: Some(env.clone()),
336        arg0: arg0.clone(),
337        stdin_mode: PipeStdinMode::Null,
338    };
339    spawn_process_internal(opts).await
340}
341
342/// Spawn a process with full options control.
343pub async fn spawn_process_with_options(opts: PipeSpawnOptions) -> Result<SpawnedProcess> {
344    spawn_process_internal(opts).await
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use assert_fs::TempDir;
351
352    fn find_echo_command() -> Option<(String, Vec<String>)> {
353        #[cfg(windows)]
354        {
355            Some(("cmd.exe".to_string(), vec!["/C".to_string(), "echo".to_string()]))
356        }
357        #[cfg(not(windows))]
358        {
359            Some(("echo".to_string(), vec![]))
360        }
361    }
362
363    #[tokio::test]
364    async fn test_spawn_process_echo() -> Result<()> {
365        let Some((program, mut base_args)) = find_echo_command() else {
366            return Ok(());
367        };
368
369        base_args.push("hello".to_string());
370
371        let env: HashMap<String, String> = std::env::vars().collect();
372        let spawned = spawn_process(&program, &base_args, Path::new("."), &env, &None).await?;
373
374        let exit_code = spawned.exit_rx.await.unwrap_or(-1);
375        assert_eq!(exit_code, 0);
376
377        Ok(())
378    }
379
380    #[tokio::test]
381    async fn test_spawn_options_builder() {
382        let opts = PipeSpawnOptions::new("echo", ".")
383            .args(["hello", "world"])
384            .stdin_mode(PipeStdinMode::Null);
385
386        assert_eq!(opts.program, "echo");
387        assert_eq!(opts.args, vec!["hello", "world"]);
388        assert!(matches!(opts.stdin_mode, PipeStdinMode::Null));
389    }
390
391    #[cfg(unix)]
392    #[tokio::test]
393    async fn test_spawn_process_detaches_from_tty() {
394        let dir = TempDir::new().expect("tempdir");
395        let env: HashMap<String, String> = HashMap::new();
396
397        let spawned = spawn_process("sh", &["-c".into(), "echo ok".into()], dir.path(), &env, &None)
398            .await
399            .expect("spawn");
400
401        let exit_code = spawned.exit_rx.await.unwrap_or(-1);
402        assert_eq!(exit_code, 0);
403    }
404}