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, BytesMut};
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
54const RELIABLE_OUTPUT_CHANNEL_CAPACITY: usize = 128;
55
56/// Read from an async reader and send chunks to both compatibility and
57/// optional lossless output channels. The compatibility broadcast is always
58/// independent: callers that only use the legacy receiver must never be able
59/// to stop the child-process pipe from being drained.
60async fn read_output_stream<R>(
61    mut reader: R,
62    output_tx: broadcast::Sender<Bytes>,
63    mut reliable_output_tx: Option<mpsc::Sender<Bytes>>,
64) where
65    R: AsyncRead + Unpin,
66{
67    // Read directly into a BytesMut so each chunk can be frozen without a
68    // separate Vec-to-Bytes copy.
69    let mut buf = BytesMut::with_capacity(65_536);
70    loop {
71        buf.clear();
72        match reader.read_buf(&mut buf).await {
73            Ok(0) => break,
74            Ok(n) => {
75                let chunk = buf.split_to(n).freeze();
76                let _ = output_tx.send(chunk.clone());
77                if let Some(sender) = reliable_output_tx.as_ref().cloned()
78                    && sender.send(chunk).await.is_err()
79                {
80                    // A lossless consumer is optional. Once it goes away,
81                    // continue draining the child stream for compatibility
82                    // subscribers instead of deadlocking the child.
83                    reliable_output_tx = None;
84                }
85            }
86            Err(ref e) if e.kind() == ErrorKind::Interrupted => continue,
87            Err(_) => break,
88        }
89    }
90}
91
92/// Stdin mode for pipe-based processes.
93#[derive(Clone, Copy)]
94pub enum PipeStdinMode {
95    /// Stdin is available as a pipe.
96    Piped,
97    /// Stdin is connected to /dev/null (immediate EOF).
98    Null,
99}
100
101/// Options for spawning a pipe-based process.
102#[derive(Clone)]
103#[must_use = "spawn options must be passed to a spawn function"]
104pub struct PipeSpawnOptions {
105    /// The program to execute.
106    program: String,
107    /// Arguments to pass to the program.
108    args: Vec<String>,
109    /// Working directory for the process.
110    cwd: std::path::PathBuf,
111    /// Environment variables (if None, inherits from parent).
112    env: Option<HashMap<String, String>>,
113    /// Override for `argv[0]` (Unix only).
114    arg0: Option<String>,
115    /// Stdin mode.
116    stdin_mode: PipeStdinMode,
117    /// Enable the bounded lossless stream used by spoolers and
118    /// `wait_with_output`. Legacy callers should leave this disabled so an
119    /// unconsumed reliable receiver cannot apply backpressure.
120    lossless_output: bool,
121}
122
123impl PipeSpawnOptions {
124    /// Create new spawn options with default settings.
125    pub fn new(program: impl Into<String>, cwd: impl Into<std::path::PathBuf>) -> Self {
126        Self {
127            program: program.into(),
128            args: Vec::new(),
129            cwd: cwd.into(),
130            env: None,
131            arg0: None,
132            stdin_mode: PipeStdinMode::Piped,
133            lossless_output: false,
134        }
135    }
136
137    /// Add arguments.
138    pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
139        self.args = args.into_iter().map(Into::into).collect();
140        self
141    }
142
143    /// Set environment variables.
144    pub fn env(mut self, env: HashMap<String, String>) -> Self {
145        self.env = Some(env);
146        self
147    }
148
149    /// Set arg0 override (Unix only).
150    pub fn arg0(mut self, arg0: impl Into<String>) -> Self {
151        self.arg0 = Some(arg0.into());
152        self
153    }
154
155    /// Set stdin mode.
156    pub fn stdin_mode(mut self, mode: PipeStdinMode) -> Self {
157        self.stdin_mode = mode;
158        self
159    }
160
161    /// Enable the lossless output stream for a consumer that drains it.
162    pub fn lossless_output(mut self, enabled: bool) -> Self {
163        self.lossless_output = enabled;
164        self
165    }
166}
167
168/// Spawn a process using regular pipes, with configurable options.
169async fn spawn_process_internal(opts: PipeSpawnOptions) -> Result<SpawnedProcess> {
170    if opts.program.is_empty() {
171        anyhow::bail!("missing program for pipe spawn");
172    }
173
174    #[cfg(unix)]
175    let owned = crate::process_owner::OwnedCommand::from_environment(
176        opts.program.as_ref(),
177        opts.args.iter(),
178        opts.arg0.as_deref().map(std::ffi::OsStr::new),
179    )?;
180
181    #[cfg(unix)]
182    let mut command = match &owned {
183        Some(owned) => {
184            let mut command = Command::new(&owned.executable);
185            command.args(&owned.arguments);
186            command
187        }
188        None => Command::new(&opts.program),
189    };
190    #[cfg(not(unix))]
191    let mut command = Command::new(&opts.program);
192
193    #[cfg(unix)]
194    if owned.is_none()
195        && let Some(ref arg0) = opts.arg0
196    {
197        command.arg0(arg0);
198    }
199
200    #[cfg(unix)]
201    #[expect(
202        unsafe_code,
203        reason = "detach_from_tty only calls setsid/setpgid via safe nix wrappers to detach the child from the controlling terminal; it is a pure process-group operation with no undefined behavior"
204    )]
205    // SAFETY: `pre_exec` runs the closure in the forked child before `exec`,
206    // so only async-signal-safe operations are permitted. `detach_from_tty`
207    // calls `setsid()` (falling back to `setpgid(0, 0)` on EPERM) through safe
208    // `nix` wrappers — both are async-signal-safe POSIX calls that perform no
209    // allocation, no locking, and no mutable aliasing of process memory.
210    unsafe {
211        command.pre_exec(process_group::detach_from_tty);
212    }
213
214    #[cfg(not(unix))]
215    let _ = &opts.arg0;
216
217    command.current_dir(&opts.cwd);
218
219    // Handle environment
220    if let Some(ref env) = opts.env {
221        command.env_clear();
222        for (key, value) in env {
223            command.env(key, value);
224        }
225    }
226
227    #[cfg(unix)]
228    if let Some(owned) = &owned {
229        // Environment filtering remains authoritative; add only this private
230        // lifetime endpoint after applying the admitted command's environment.
231        command.env(crate::process_owner::OWNER_SOCKET_ENV, &owned.owner_socket);
232    } else {
233        command.args(&opts.args);
234    }
235    #[cfg(not(unix))]
236    command.args(&opts.args);
237
238    match opts.stdin_mode {
239        PipeStdinMode::Piped => {
240            command.stdin(Stdio::piped());
241        }
242        PipeStdinMode::Null => {
243            command.stdin(Stdio::null());
244        }
245    }
246    command.stdout(Stdio::piped());
247    command.stderr(Stdio::piped());
248
249    let mut child = command.spawn().context("failed to spawn pipe process")?;
250    let pid = child.id().ok_or_else(|| io::Error::other("missing child pid"))?;
251
252    #[cfg(unix)]
253    let process_group_id = pid;
254
255    let stdin = child.stdin.take();
256    let stdout = child.stdout.take();
257    let stderr = child.stderr.take();
258
259    let (writer_tx, mut writer_rx) = mpsc::channel::<Vec<u8>>(128);
260    let (output_tx, _) = broadcast::channel::<Bytes>(256);
261    let initial_output_rx = output_tx.subscribe();
262    let (reliable_output_tx, reliable_output_rx) = mpsc::channel::<Bytes>(RELIABLE_OUTPUT_CHANNEL_CAPACITY);
263    let reliable_output_enabled = opts.lossless_output;
264
265    // Spawn writer task
266    let writer_handle = if let Some(stdin) = stdin {
267        let writer = Arc::new(tokio::sync::Mutex::new(stdin));
268        tokio::spawn(async move {
269            while let Some(bytes) = writer_rx.recv().await {
270                let mut guard = writer.lock().await;
271                let _ = guard.write_all(&bytes).await;
272                let _ = guard.flush().await;
273            }
274        })
275    } else {
276        drop(writer_rx);
277        tokio::spawn(async {})
278    };
279
280    // Spawn reader tasks for stdout and stderr
281    let stdout_handle = stdout.map(|stdout| {
282        let output_tx = output_tx.clone();
283        let reliable_output_tx = opts.lossless_output.then(|| reliable_output_tx.clone());
284        tokio::spawn(async move {
285            read_output_stream(BufReader::new(stdout), output_tx, reliable_output_tx).await;
286        })
287    });
288
289    let stderr_handle = stderr.map(|stderr| {
290        let output_tx = output_tx.clone();
291        let reliable_output_tx = opts.lossless_output.then(|| reliable_output_tx.clone());
292        tokio::spawn(async move {
293            read_output_stream(BufReader::new(stderr), output_tx, reliable_output_tx).await;
294        })
295    });
296    drop(reliable_output_tx);
297
298    let mut reader_abort_handles = Vec::new();
299    if let Some(ref handle) = stdout_handle {
300        reader_abort_handles.push(handle.abort_handle());
301    }
302    if let Some(ref handle) = stderr_handle {
303        reader_abort_handles.push(handle.abort_handle());
304    }
305
306    let reader_handle = tokio::spawn(async move {
307        if let Some(handle) = stdout_handle {
308            let _ = handle.await;
309        }
310        if let Some(handle) = stderr_handle {
311            let _ = handle.await;
312        }
313    });
314
315    // Spawn wait task
316    let (exit_tx, exit_rx) = oneshot::channel::<i32>();
317    let exit_status = Arc::new(AtomicBool::new(false));
318    let wait_exit_status = Arc::clone(&exit_status);
319    let exit_code = Arc::new(StdMutex::new(None));
320    let wait_exit_code = Arc::clone(&exit_code);
321
322    let wait_handle: JoinHandle<()> = tokio::spawn(async move {
323        let code = match child.wait().await {
324            Ok(status) => status.code().unwrap_or(-1),
325            Err(_) => -1,
326        };
327        wait_exit_status.store(true, std::sync::atomic::Ordering::SeqCst);
328        if let Ok(mut guard) = wait_exit_code.lock() {
329            *guard = Some(code);
330        }
331        let _ = exit_tx.send(code);
332    });
333
334    let (handle, output_rx) = ProcessHandle::new(
335        writer_tx,
336        output_tx,
337        initial_output_rx,
338        Box::new(PipeChildTerminator {
339            #[cfg(windows)]
340            pid,
341            #[cfg(unix)]
342            process_group_id,
343        }),
344        reader_handle,
345        reader_abort_handles,
346        writer_handle,
347        wait_handle,
348        exit_status,
349        exit_code,
350        None,
351    );
352
353    Ok(SpawnedProcess {
354        session: handle,
355        process_id: pid,
356        output_rx,
357        reliable_output_rx,
358        reliable_output_enabled,
359        exit_rx,
360    })
361}
362
363/// Spawn a process using regular pipes (no PTY), returning handles for stdin, output, and exit.
364///
365/// Copies the borrowed arguments, environment, and optional `arg0` into owned
366/// spawn options. `arg0` overrides Unix `argv[0]`; other platforms ignore it.
367///
368/// # Errors
369/// Returns launch or pipe setup failures with process context.
370///
371/// # Example
372/// ```ignore
373/// use vtcode_bash_runner::pipe::spawn_process;
374/// use hashbrown::HashMap;
375/// use std::path::Path;
376///
377/// let env: HashMap<String, String> = std::env::vars().collect();
378/// let spawned = spawn_process("echo", &["hello".into()], Path::new("."), &env, None).await?;
379/// let output_rx = spawned.output_rx;
380/// let exit_code = spawned.exit_rx.await?;
381/// ```
382pub async fn spawn_process(
383    program: &str,
384    args: &[String],
385    cwd: &Path,
386    env: &HashMap<String, String>,
387    arg0: Option<&str>,
388) -> Result<SpawnedProcess> {
389    let opts = PipeSpawnOptions {
390        program: program.to_string(),
391        args: args.to_vec(),
392        cwd: cwd.to_path_buf(),
393        env: Some(env.clone()),
394        arg0: arg0.map(str::to_owned),
395        stdin_mode: PipeStdinMode::Piped,
396        lossless_output: false,
397    };
398    spawn_process_internal(opts).await
399}
400
401/// Spawn a process using regular pipes, but close stdin immediately.
402///
403/// This is useful for commands that should see EOF on stdin immediately.
404/// Borrowed inputs and Unix `arg0` have the same semantics as [`spawn_process`].
405///
406/// # Errors
407/// Returns launch or pipe setup failures with process context.
408pub async fn spawn_process_no_stdin(
409    program: &str,
410    args: &[String],
411    cwd: &Path,
412    env: &HashMap<String, String>,
413    arg0: Option<&str>,
414) -> Result<SpawnedProcess> {
415    let opts = PipeSpawnOptions {
416        program: program.to_string(),
417        args: args.to_vec(),
418        cwd: cwd.to_path_buf(),
419        env: Some(env.clone()),
420        arg0: arg0.map(str::to_owned),
421        stdin_mode: PipeStdinMode::Null,
422        lossless_output: false,
423    };
424    spawn_process_internal(opts).await
425}
426
427/// Spawn a process with full options control.
428pub async fn spawn_process_with_options(opts: PipeSpawnOptions) -> Result<SpawnedProcess> {
429    spawn_process_internal(opts).await
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use assert_fs::TempDir;
436
437    fn find_echo_command() -> Option<(String, Vec<String>)> {
438        #[cfg(windows)]
439        {
440            Some(("cmd.exe".to_string(), vec!["/C".to_string(), "echo".to_string()]))
441        }
442        #[cfg(not(windows))]
443        {
444            Some(("echo".to_string(), vec![]))
445        }
446    }
447
448    #[tokio::test]
449    async fn test_spawn_process_echo() -> Result<()> {
450        let Some((program, mut base_args)) = find_echo_command() else {
451            return Ok(());
452        };
453
454        base_args.push("hello".to_string());
455
456        let env: HashMap<String, String> = std::env::vars().collect();
457        let spawned = spawn_process(&program, &base_args, Path::new("."), &env, None).await?;
458
459        let exit_code = spawned.exit_rx.await.unwrap_or(-1);
460        assert_eq!(exit_code, 0);
461
462        Ok(())
463    }
464
465    #[tokio::test]
466    async fn test_spawn_options_builder() {
467        let opts = PipeSpawnOptions::new("echo", ".")
468            .args(["hello", "world"])
469            .stdin_mode(PipeStdinMode::Null);
470
471        assert_eq!(opts.program, "echo");
472        assert_eq!(opts.args, vec!["hello", "world"]);
473        assert!(matches!(opts.stdin_mode, PipeStdinMode::Null));
474    }
475
476    #[cfg(unix)]
477    #[tokio::test]
478    async fn test_spawn_process_detaches_from_tty() {
479        let dir = TempDir::new().expect("tempdir");
480        let env: HashMap<String, String> = HashMap::new();
481
482        let spawned = spawn_process("sh", &["-c".into(), "echo ok".into()], dir.path(), &env, None)
483            .await
484            .expect("spawn");
485
486        let exit_code = spawned.exit_rx.await.unwrap_or(-1);
487        assert_eq!(exit_code, 0);
488    }
489}