Skip to main content

command_stream/
stream.rs

1//! Streaming and async iteration support
2//!
3//! This module provides async streaming capabilities similar to JavaScript's
4//! async iterators and stream handling in `$.stream-utils.mjs`.
5//!
6//! It mirrors the JavaScript implementation's behavior for issue #155:
7//!
8//!   1. The stream yields an explicit `OutputChunk::Exit(code)` when the
9//!      process exits, so consumers can observe the exit code from inside the
10//!      loop.
11//!   2. The stream does not hang forever when the process has exited but a
12//!      grandchild keeps the stdio pipes open (the readers are drained with a
13//!      grace period and then aborted).
14//!   3. The process can be stopped from inside the loop via
15//!      [`OutputStream::kill`] / [`OutputStream::kill_with`], and abandoning the
16//!      stream (e.g. `break`) also stops the process.
17//!   4. The stop signal is configurable via
18//!      [`StreamingRunner::kill_signal`] (default `SIGTERM`), just like the
19//!      JavaScript `killSignal` option.
20//!
21//! ## Usage
22//!
23//! ```rust,no_run
24//! use command_stream::{StreamingRunner, OutputChunk};
25//!
26//! #[tokio::main]
27//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
28//!     let runner = StreamingRunner::new("yes hello");
29//!
30//!     // Stream output as it arrives
31//!     let mut stream = runner.stream();
32//!     let mut count = 0;
33//!     while let Some(chunk) = stream.next().await {
34//!         match chunk {
35//!             OutputChunk::Stdout(data) => {
36//!                 print!("{}", String::from_utf8_lossy(&data));
37//!                 count += 1;
38//!                 if count >= 5 {
39//!                     // Stop the process from inside the loop.
40//!                     stream.kill();
41//!                 }
42//!             }
43//!             OutputChunk::Stderr(data) => {
44//!                 eprint!("{}", String::from_utf8_lossy(&data));
45//!             }
46//!             OutputChunk::Exit(code) => {
47//!                 println!("Process exited with code: {}", code);
48//!                 break;
49//!             }
50//!         }
51//!     }
52//!
53//!     Ok(())
54//! }
55//! ```
56
57use std::collections::HashMap;
58use std::ffi::OsString;
59use std::path::PathBuf;
60use std::process::Stdio;
61use std::time::Duration;
62use tokio::io::BufReader;
63use tokio::process::Command;
64use tokio::sync::mpsc;
65
66use crate::trace::trace_lazy;
67use crate::{CommandResult, Result};
68
69/// Default grace period (in milliseconds) to keep draining the stdio pipes
70/// after the process has exited before aborting any lingering readers. Mirrors
71/// the JavaScript `exitPumpGrace` default.
72const DEFAULT_EXIT_PUMP_GRACE_MS: u64 = 100;
73
74/// Default signal used to stop a process when no explicit signal is given.
75const DEFAULT_KILL_SIGNAL: &str = "SIGTERM";
76
77/// A chunk of output from a streaming process
78#[derive(Debug, Clone)]
79pub enum OutputChunk {
80    /// Stdout data
81    Stdout(Vec<u8>),
82    /// Stderr data
83    Stderr(Vec<u8>),
84    /// Process exit code
85    Exit(i32),
86}
87
88/// A streaming process runner that allows async iteration over output
89pub struct StreamingRunner {
90    command: StreamingCommand,
91    cwd: Option<PathBuf>,
92    env: Option<HashMap<String, String>>,
93    stdin_content: Option<String>,
94    kill_signal: String,
95    exit_pump_grace_ms: u64,
96}
97
98#[derive(Clone)]
99enum StreamingCommand {
100    Shell(String),
101    Argv {
102        program: OsString,
103        args: Vec<OsString>,
104    },
105}
106
107impl StreamingRunner {
108    /// Create a streaming runner for a command string interpreted by the
109    /// platform shell.
110    pub fn new(command: impl Into<String>) -> Self {
111        Self::with_command(StreamingCommand::Shell(command.into()))
112    }
113
114    /// Create a streaming runner for an executable and exact argument vector.
115    ///
116    /// Unlike [`StreamingRunner::new`], this constructor bypasses the platform
117    /// shell. Argument boundaries are therefore preserved on every platform,
118    /// including Windows, without requiring shell-specific quoting.
119    pub fn from_argv<P, I, S>(program: P, args: I) -> Self
120    where
121        P: Into<OsString>,
122        I: IntoIterator<Item = S>,
123        S: Into<OsString>,
124    {
125        Self::with_command(StreamingCommand::Argv {
126            program: program.into(),
127            args: args.into_iter().map(Into::into).collect(),
128        })
129    }
130
131    fn with_command(command: StreamingCommand) -> Self {
132        StreamingRunner {
133            command,
134            cwd: None,
135            env: None,
136            stdin_content: None,
137            kill_signal: DEFAULT_KILL_SIGNAL.to_string(),
138            exit_pump_grace_ms: DEFAULT_EXIT_PUMP_GRACE_MS,
139        }
140    }
141
142    /// Set the working directory
143    pub fn cwd(mut self, path: impl Into<PathBuf>) -> Self {
144        self.cwd = Some(path.into());
145        self
146    }
147
148    /// Set environment variables
149    pub fn env(mut self, env: HashMap<String, String>) -> Self {
150        self.env = Some(env);
151        self
152    }
153
154    /// Set stdin content
155    pub fn stdin(mut self, content: impl Into<String>) -> Self {
156        self.stdin_content = Some(content.into());
157        self
158    }
159
160    /// Configure the signal used to stop the process when it is killed without
161    /// an explicit signal — i.e. [`OutputStream::kill`] or abandoning the
162    /// stream. Mirrors the JavaScript `killSignal` option (default `SIGTERM`).
163    ///
164    /// The reported exit code follows the conventional `128 + signal` mapping
165    /// (e.g. `SIGTERM` => 143, `SIGINT` => 130, `SIGKILL` => 137).
166    pub fn kill_signal(mut self, signal: impl Into<String>) -> Self {
167        self.kill_signal = signal.into();
168        self
169    }
170
171    /// Configure the grace period (in milliseconds) to keep draining the stdio
172    /// pipes after the process exits before aborting lingering readers. Mirrors
173    /// the JavaScript `exitPumpGrace` option (default 100ms).
174    pub fn exit_pump_grace_ms(mut self, ms: u64) -> Self {
175        self.exit_pump_grace_ms = ms;
176        self
177    }
178
179    /// Start the process and return a stream of output chunks
180    pub fn stream(mut self) -> OutputStream {
181        let (tx, rx) = mpsc::channel(1024);
182        // Unbounded so a synchronous Drop can request a kill without awaiting.
183        let (kill_tx, kill_rx) = mpsc::unbounded_channel::<String>();
184
185        // Spawn the process handling task
186        let command = self.command.clone();
187        let cwd = self.cwd.take();
188        let env = self.env.take();
189        let stdin_content = self.stdin_content.take();
190        let grace = self.exit_pump_grace_ms;
191        let kill_signal = self.kill_signal.clone();
192
193        tokio::spawn(async move {
194            if let Err(e) =
195                run_streaming_process(command, cwd, env, stdin_content, grace, tx.clone(), kill_rx)
196                    .await
197            {
198                trace_lazy("StreamingRunner", || format!("Error: {}", e));
199            }
200        });
201
202        OutputStream {
203            rx,
204            kill_tx,
205            kill_signal,
206            killed: false,
207        }
208    }
209
210    /// Run to completion and collect all output
211    pub async fn collect(self) -> Result<CommandResult> {
212        let mut stdout = Vec::new();
213        let mut stderr = Vec::new();
214        let mut exit_code = 0;
215
216        let mut stream = self.stream();
217        while let Some(chunk) = stream.rx.recv().await {
218            match chunk {
219                OutputChunk::Stdout(data) => stdout.extend(data),
220                OutputChunk::Stderr(data) => stderr.extend(data),
221                OutputChunk::Exit(code) => exit_code = code,
222            }
223        }
224
225        Ok(CommandResult {
226            stdout: String::from_utf8_lossy(&stdout).to_string(),
227            stderr: String::from_utf8_lossy(&stderr).to_string(),
228            code: exit_code,
229        })
230    }
231}
232
233/// Stream of output chunks from a process
234pub struct OutputStream {
235    rx: mpsc::Receiver<OutputChunk>,
236    kill_tx: mpsc::UnboundedSender<String>,
237    kill_signal: String,
238    killed: bool,
239}
240
241impl OutputStream {
242    /// Receive the next chunk
243    pub async fn next(&mut self) -> Option<OutputChunk> {
244        self.rx.recv().await
245    }
246
247    /// Stop the process using the configured kill signal (default `SIGTERM`).
248    ///
249    /// This can be called from inside the consumption loop to stop a
250    /// long-running or endless process; a terminating `OutputChunk::Exit` is
251    /// still delivered afterwards.
252    pub fn kill(&mut self) {
253        let signal = self.kill_signal.clone();
254        self.kill_with(&signal);
255    }
256
257    /// Stop the process using an explicit signal, overriding the configured
258    /// kill signal for this call.
259    pub fn kill_with(&mut self, signal: &str) {
260        if self.killed {
261            return;
262        }
263        self.killed = true;
264        trace_lazy("OutputStream", || format!("kill | signal={}", signal));
265        // Best effort: the task may have already finished, in which case the
266        // receiver is gone and the send fails harmlessly.
267        let _ = self.kill_tx.send(signal.to_string());
268    }
269
270    /// Collect all remaining output into vectors
271    pub async fn collect(mut self) -> (Vec<u8>, Vec<u8>, i32) {
272        let mut stdout = Vec::new();
273        let mut stderr = Vec::new();
274        let mut exit_code = 0;
275
276        while let Some(chunk) = self.rx.recv().await {
277            match chunk {
278                OutputChunk::Stdout(data) => stdout.extend(data),
279                OutputChunk::Stderr(data) => stderr.extend(data),
280                OutputChunk::Exit(code) => exit_code = code,
281            }
282        }
283
284        (stdout, stderr, exit_code)
285    }
286
287    /// Collect stdout only, discarding stderr
288    pub async fn collect_stdout(mut self) -> Vec<u8> {
289        let mut stdout = Vec::new();
290
291        while let Some(chunk) = self.rx.recv().await {
292            if let OutputChunk::Stdout(data) = chunk {
293                stdout.extend(data);
294            }
295        }
296
297        stdout
298    }
299}
300
301impl Drop for OutputStream {
302    fn drop(&mut self) {
303        // Abandoning the stream (e.g. `break`-ing out of the loop) must stop the
304        // process, matching the JavaScript iterator's `finally` cleanup. If the
305        // process already finished this is a harmless no-op.
306        if !self.killed {
307            let _ = self.kill_tx.send(self.kill_signal.clone());
308        }
309    }
310}
311
312/// Run a streaming process and send output to the channel
313async fn run_streaming_process(
314    command: StreamingCommand,
315    cwd: Option<PathBuf>,
316    env: Option<HashMap<String, String>>,
317    stdin_content: Option<String>,
318    exit_pump_grace_ms: u64,
319    tx: mpsc::Sender<OutputChunk>,
320    mut kill_rx: mpsc::UnboundedReceiver<String>,
321) -> Result<()> {
322    trace_lazy("StreamingRunner", || match &command {
323        StreamingCommand::Shell(command) => format!("Starting: {command}"),
324        StreamingCommand::Argv { program, args } => {
325            format!("Starting argv command: {program:?} {args:?}")
326        }
327    });
328
329    let mut cmd = match command {
330        StreamingCommand::Shell(command) => {
331            let shell = find_available_shell();
332            let mut cmd = Command::new(&shell.cmd);
333            cmd.args(&shell.args)
334                .arg(crate::utils::with_exported_process_context(
335                    &command,
336                    env.as_ref(),
337                ));
338            cmd
339        }
340        StreamingCommand::Argv { program, args } => {
341            let mut cmd = Command::new(program);
342            cmd.args(args);
343            cmd
344        }
345    };
346
347    // Configure stdio
348    if stdin_content.is_some() {
349        cmd.stdin(Stdio::piped());
350    } else {
351        cmd.stdin(Stdio::null());
352    }
353    cmd.stdout(Stdio::piped());
354    cmd.stderr(Stdio::piped());
355
356    // Run the child in its own process group so we can signal the whole group
357    // (parent + grandchildren), matching the JavaScript implementation.
358    #[cfg(unix)]
359    cmd.process_group(0);
360
361    // Set working directory
362    if let Some(ref cwd) = cwd {
363        cmd.current_dir(cwd);
364    }
365
366    // Set environment
367    if let Some(ref env_vars) = env {
368        for (key, value) in env_vars {
369            cmd.env(key, value);
370        }
371    }
372
373    let mut child = cmd.spawn()?;
374
375    // Write stdin if needed
376    if let Some(content) = stdin_content {
377        if let Some(mut stdin) = child.stdin.take() {
378            use tokio::io::AsyncWriteExt;
379            let _ = stdin.write_all(content.as_bytes()).await;
380            let _ = stdin.shutdown().await;
381        }
382    }
383
384    // Spawn stdout reader
385    let stdout = child.stdout.take();
386    let tx_stdout = tx.clone();
387    let stdout_handle = stdout.map(|stdout| {
388        tokio::spawn(async move {
389            let mut reader = BufReader::new(stdout);
390            let mut buf = vec![0u8; 8192];
391            loop {
392                use tokio::io::AsyncReadExt;
393                match reader.read(&mut buf).await {
394                    Ok(0) => break,
395                    Ok(n) => {
396                        if tx_stdout
397                            .send(OutputChunk::Stdout(buf[..n].to_vec()))
398                            .await
399                            .is_err()
400                        {
401                            break;
402                        }
403                    }
404                    Err(_) => break,
405                }
406            }
407        })
408    });
409
410    // Spawn stderr reader
411    let stderr = child.stderr.take();
412    let tx_stderr = tx.clone();
413    let stderr_handle = stderr.map(|stderr| {
414        tokio::spawn(async move {
415            let mut reader = BufReader::new(stderr);
416            let mut buf = vec![0u8; 8192];
417            loop {
418                use tokio::io::AsyncReadExt;
419                match reader.read(&mut buf).await {
420                    Ok(0) => break,
421                    Ok(n) => {
422                        if tx_stderr
423                            .send(OutputChunk::Stderr(buf[..n].to_vec()))
424                            .await
425                            .is_err()
426                        {
427                            break;
428                        }
429                    }
430                    Err(_) => break,
431                }
432            }
433        })
434    });
435
436    // Wait for the process to exit OR for a kill request — crucially we do NOT
437    // wait for the readers first. If a grandchild keeps the pipe open the
438    // readers would never finish, so waiting on them before the exit (as the
439    // old implementation did) would hang forever (issue #155).
440    let pid = child.id();
441    let code;
442    tokio::select! {
443        status = child.wait() => {
444            code = status_to_code(status?);
445        }
446        maybe_signal = kill_rx.recv() => {
447            // A kill was requested (explicit kill()/kill_with() or the stream
448            // being dropped). Stop the process group with the requested signal.
449            let signal = maybe_signal.unwrap_or_else(|| DEFAULT_KILL_SIGNAL.to_string());
450            trace_lazy("StreamingRunner", || format!("Kill requested | signal={}", signal));
451            if let Some(pid) = pid {
452                send_signal_to_process(pid, &signal);
453            }
454            // Give it a brief moment to exit on the requested signal, then
455            // escalate to a forceful kill so it always terminates.
456            if tokio::time::timeout(Duration::from_millis(exit_pump_grace_ms), child.wait())
457                .await
458                .is_err()
459            {
460                let _ = child.start_kill();
461                let _ = child.wait().await;
462            }
463            // Report the conventional 128 + signal code for the requested
464            // signal, matching the JavaScript implementation.
465            code = 128 + signal_number(&signal);
466        }
467    }
468
469    // The process has exited. Give the readers a short grace period to flush any
470    // buffered output, then abort any that are still blocked on an inherited
471    // open pipe so we don't hang.
472    let stdout_abort = stdout_handle.as_ref().map(|h| h.abort_handle());
473    let stderr_abort = stderr_handle.as_ref().map(|h| h.abort_handle());
474    let drain = async {
475        if let Some(handle) = stdout_handle {
476            let _ = handle.await;
477        }
478        if let Some(handle) = stderr_handle {
479            let _ = handle.await;
480        }
481    };
482    if tokio::time::timeout(Duration::from_millis(exit_pump_grace_ms), drain)
483        .await
484        .is_err()
485    {
486        // A reader is still blocked on an inherited open pipe — abort it so the
487        // exit chunk is delivered without waiting for the grandchild.
488        if let Some(abort) = stdout_abort {
489            abort.abort();
490        }
491        if let Some(abort) = stderr_abort {
492            abort.abort();
493        }
494    }
495
496    // Send exit code (always — even if a reader was aborted).
497    let _ = tx.send(OutputChunk::Exit(code)).await;
498
499    trace_lazy("StreamingRunner", || format!("Exited with code: {}", code));
500
501    Ok(())
502}
503
504/// Convert an exit status into a numeric exit code, using the conventional
505/// `128 + signal` mapping when the process was terminated by a signal.
506fn status_to_code(status: std::process::ExitStatus) -> i32 {
507    if let Some(code) = status.code() {
508        return code;
509    }
510    #[cfg(unix)]
511    {
512        use std::os::unix::process::ExitStatusExt;
513        if let Some(sig) = status.signal() {
514            return 128 + sig;
515        }
516    }
517    -1
518}
519
520/// Map a signal name to its numeric value for the `128 + signal` exit-code
521/// convention. Unknown names fall back to `SIGTERM`.
522fn signal_number(signal: &str) -> i32 {
523    match signal {
524        "SIGHUP" => 1,
525        "SIGINT" => 2,
526        "SIGQUIT" => 3,
527        "SIGKILL" => 9,
528        "SIGUSR1" => 10,
529        "SIGUSR2" => 12,
530        "SIGTERM" => 15,
531        _ => 15,
532    }
533}
534
535/// Send a signal to a process and its process group (best effort).
536#[cfg(unix)]
537fn send_signal_to_process(pid: u32, signal: &str) {
538    use nix::sys::signal::{kill, Signal};
539    use nix::unistd::Pid;
540
541    let sig = match signal {
542        "SIGHUP" => Signal::SIGHUP,
543        "SIGINT" => Signal::SIGINT,
544        "SIGQUIT" => Signal::SIGQUIT,
545        "SIGKILL" => Signal::SIGKILL,
546        "SIGUSR1" => Signal::SIGUSR1,
547        "SIGUSR2" => Signal::SIGUSR2,
548        "SIGTERM" => Signal::SIGTERM,
549        _ => Signal::SIGTERM,
550    };
551
552    // Signal the process itself.
553    let _ = kill(Pid::from_raw(pid as i32), sig);
554    // Signal the whole process group (negative pid) to reach grandchildren.
555    let _ = kill(Pid::from_raw(-(pid as i32)), sig);
556}
557
558/// On non-Unix platforms there is no signal delivery; the forceful
559/// `start_kill()` escalation in the caller handles termination.
560#[cfg(not(unix))]
561fn send_signal_to_process(_pid: u32, _signal: &str) {}
562
563/// Shell configuration
564#[derive(Debug, Clone)]
565struct ShellConfig {
566    cmd: String,
567    args: Vec<String>,
568}
569
570/// Find an available shell
571fn find_available_shell() -> ShellConfig {
572    let is_windows = cfg!(windows);
573
574    if is_windows {
575        ShellConfig {
576            cmd: "cmd.exe".to_string(),
577            args: vec!["/c".to_string()],
578        }
579    } else {
580        let shells = [
581            ("/bin/sh", "-c"),
582            ("/usr/bin/sh", "-c"),
583            ("/bin/bash", "-c"),
584        ];
585
586        for (cmd, arg) in shells {
587            if std::path::Path::new(cmd).exists() {
588                return ShellConfig {
589                    cmd: cmd.to_string(),
590                    args: vec![arg.to_string()],
591                };
592            }
593        }
594
595        ShellConfig {
596            cmd: "/bin/sh".to_string(),
597            args: vec!["-c".to_string()],
598        }
599    }
600}
601
602/// Async iterator trait for output streams
603#[async_trait::async_trait]
604pub trait AsyncIterator {
605    type Item;
606
607    /// Get the next item from the iterator
608    async fn next(&mut self) -> Option<Self::Item>;
609}
610
611#[async_trait::async_trait]
612impl AsyncIterator for OutputStream {
613    type Item = OutputChunk;
614
615    async fn next(&mut self) -> Option<Self::Item> {
616        self.rx.recv().await
617    }
618}
619
620/// Extension trait to convert ProcessRunner into a stream
621pub trait IntoStream {
622    /// Convert into an output stream
623    fn into_stream(self) -> OutputStream;
624}
625
626impl IntoStream for crate::ProcessRunner {
627    fn into_stream(self) -> OutputStream {
628        let streaming = StreamingRunner::new(self.command().to_string());
629        streaming.stream()
630    }
631}