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).arg(command);
334            cmd
335        }
336        StreamingCommand::Argv { program, args } => {
337            let mut cmd = Command::new(program);
338            cmd.args(args);
339            cmd
340        }
341    };
342
343    // Configure stdio
344    if stdin_content.is_some() {
345        cmd.stdin(Stdio::piped());
346    } else {
347        cmd.stdin(Stdio::null());
348    }
349    cmd.stdout(Stdio::piped());
350    cmd.stderr(Stdio::piped());
351
352    // Run the child in its own process group so we can signal the whole group
353    // (parent + grandchildren), matching the JavaScript implementation.
354    #[cfg(unix)]
355    cmd.process_group(0);
356
357    // Set working directory
358    if let Some(ref cwd) = cwd {
359        cmd.current_dir(cwd);
360    }
361
362    // Set environment
363    if let Some(ref env_vars) = env {
364        for (key, value) in env_vars {
365            cmd.env(key, value);
366        }
367    }
368
369    // Spawn the process
370    let mut child = cmd.spawn()?;
371
372    // Write stdin if needed
373    if let Some(content) = stdin_content {
374        if let Some(mut stdin) = child.stdin.take() {
375            use tokio::io::AsyncWriteExt;
376            let _ = stdin.write_all(content.as_bytes()).await;
377            let _ = stdin.shutdown().await;
378        }
379    }
380
381    // Spawn stdout reader
382    let stdout = child.stdout.take();
383    let tx_stdout = tx.clone();
384    let stdout_handle = stdout.map(|stdout| {
385        tokio::spawn(async move {
386            let mut reader = BufReader::new(stdout);
387            let mut buf = vec![0u8; 8192];
388            loop {
389                use tokio::io::AsyncReadExt;
390                match reader.read(&mut buf).await {
391                    Ok(0) => break,
392                    Ok(n) => {
393                        if tx_stdout
394                            .send(OutputChunk::Stdout(buf[..n].to_vec()))
395                            .await
396                            .is_err()
397                        {
398                            break;
399                        }
400                    }
401                    Err(_) => break,
402                }
403            }
404        })
405    });
406
407    // Spawn stderr reader
408    let stderr = child.stderr.take();
409    let tx_stderr = tx.clone();
410    let stderr_handle = stderr.map(|stderr| {
411        tokio::spawn(async move {
412            let mut reader = BufReader::new(stderr);
413            let mut buf = vec![0u8; 8192];
414            loop {
415                use tokio::io::AsyncReadExt;
416                match reader.read(&mut buf).await {
417                    Ok(0) => break,
418                    Ok(n) => {
419                        if tx_stderr
420                            .send(OutputChunk::Stderr(buf[..n].to_vec()))
421                            .await
422                            .is_err()
423                        {
424                            break;
425                        }
426                    }
427                    Err(_) => break,
428                }
429            }
430        })
431    });
432
433    // Wait for the process to exit OR for a kill request — crucially we do NOT
434    // wait for the readers first. If a grandchild keeps the pipe open the
435    // readers would never finish, so waiting on them before the exit (as the
436    // old implementation did) would hang forever (issue #155).
437    let pid = child.id();
438    let code;
439    tokio::select! {
440        status = child.wait() => {
441            code = status_to_code(status?);
442        }
443        maybe_signal = kill_rx.recv() => {
444            // A kill was requested (explicit kill()/kill_with() or the stream
445            // being dropped). Stop the process group with the requested signal.
446            let signal = maybe_signal.unwrap_or_else(|| DEFAULT_KILL_SIGNAL.to_string());
447            trace_lazy("StreamingRunner", || format!("Kill requested | signal={}", signal));
448            if let Some(pid) = pid {
449                send_signal_to_process(pid, &signal);
450            }
451            // Give it a brief moment to exit on the requested signal, then
452            // escalate to a forceful kill so it always terminates.
453            if tokio::time::timeout(Duration::from_millis(exit_pump_grace_ms), child.wait())
454                .await
455                .is_err()
456            {
457                let _ = child.start_kill();
458                let _ = child.wait().await;
459            }
460            // Report the conventional 128 + signal code for the requested
461            // signal, matching the JavaScript implementation.
462            code = 128 + signal_number(&signal);
463        }
464    }
465
466    // The process has exited. Give the readers a short grace period to flush any
467    // buffered output, then abort any that are still blocked on an inherited
468    // open pipe so we don't hang.
469    let stdout_abort = stdout_handle.as_ref().map(|h| h.abort_handle());
470    let stderr_abort = stderr_handle.as_ref().map(|h| h.abort_handle());
471    let drain = async {
472        if let Some(handle) = stdout_handle {
473            let _ = handle.await;
474        }
475        if let Some(handle) = stderr_handle {
476            let _ = handle.await;
477        }
478    };
479    if tokio::time::timeout(Duration::from_millis(exit_pump_grace_ms), drain)
480        .await
481        .is_err()
482    {
483        // A reader is still blocked on an inherited open pipe — abort it so the
484        // exit chunk is delivered without waiting for the grandchild.
485        if let Some(abort) = stdout_abort {
486            abort.abort();
487        }
488        if let Some(abort) = stderr_abort {
489            abort.abort();
490        }
491    }
492
493    // Send exit code (always — even if a reader was aborted).
494    let _ = tx.send(OutputChunk::Exit(code)).await;
495
496    trace_lazy("StreamingRunner", || format!("Exited with code: {}", code));
497
498    Ok(())
499}
500
501/// Convert an exit status into a numeric exit code, using the conventional
502/// `128 + signal` mapping when the process was terminated by a signal.
503fn status_to_code(status: std::process::ExitStatus) -> i32 {
504    if let Some(code) = status.code() {
505        return code;
506    }
507    #[cfg(unix)]
508    {
509        use std::os::unix::process::ExitStatusExt;
510        if let Some(sig) = status.signal() {
511            return 128 + sig;
512        }
513    }
514    -1
515}
516
517/// Map a signal name to its numeric value for the `128 + signal` exit-code
518/// convention. Unknown names fall back to `SIGTERM`.
519fn signal_number(signal: &str) -> i32 {
520    match signal {
521        "SIGHUP" => 1,
522        "SIGINT" => 2,
523        "SIGQUIT" => 3,
524        "SIGKILL" => 9,
525        "SIGUSR1" => 10,
526        "SIGUSR2" => 12,
527        "SIGTERM" => 15,
528        _ => 15,
529    }
530}
531
532/// Send a signal to a process and its process group (best effort).
533#[cfg(unix)]
534fn send_signal_to_process(pid: u32, signal: &str) {
535    use nix::sys::signal::{kill, Signal};
536    use nix::unistd::Pid;
537
538    let sig = match signal {
539        "SIGHUP" => Signal::SIGHUP,
540        "SIGINT" => Signal::SIGINT,
541        "SIGQUIT" => Signal::SIGQUIT,
542        "SIGKILL" => Signal::SIGKILL,
543        "SIGUSR1" => Signal::SIGUSR1,
544        "SIGUSR2" => Signal::SIGUSR2,
545        "SIGTERM" => Signal::SIGTERM,
546        _ => Signal::SIGTERM,
547    };
548
549    // Signal the process itself.
550    let _ = kill(Pid::from_raw(pid as i32), sig);
551    // Signal the whole process group (negative pid) to reach grandchildren.
552    let _ = kill(Pid::from_raw(-(pid as i32)), sig);
553}
554
555/// On non-Unix platforms there is no signal delivery; the forceful
556/// `start_kill()` escalation in the caller handles termination.
557#[cfg(not(unix))]
558fn send_signal_to_process(_pid: u32, _signal: &str) {}
559
560/// Shell configuration
561#[derive(Debug, Clone)]
562struct ShellConfig {
563    cmd: String,
564    args: Vec<String>,
565}
566
567/// Find an available shell
568fn find_available_shell() -> ShellConfig {
569    let is_windows = cfg!(windows);
570
571    if is_windows {
572        ShellConfig {
573            cmd: "cmd.exe".to_string(),
574            args: vec!["/c".to_string()],
575        }
576    } else {
577        let shells = [
578            ("/bin/sh", "-c"),
579            ("/usr/bin/sh", "-c"),
580            ("/bin/bash", "-c"),
581        ];
582
583        for (cmd, arg) in shells {
584            if std::path::Path::new(cmd).exists() {
585                return ShellConfig {
586                    cmd: cmd.to_string(),
587                    args: vec![arg.to_string()],
588                };
589            }
590        }
591
592        ShellConfig {
593            cmd: "/bin/sh".to_string(),
594            args: vec!["-c".to_string()],
595        }
596    }
597}
598
599/// Async iterator trait for output streams
600#[async_trait::async_trait]
601pub trait AsyncIterator {
602    type Item;
603
604    /// Get the next item from the iterator
605    async fn next(&mut self) -> Option<Self::Item>;
606}
607
608#[async_trait::async_trait]
609impl AsyncIterator for OutputStream {
610    type Item = OutputChunk;
611
612    async fn next(&mut self) -> Option<Self::Item> {
613        self.rx.recv().await
614    }
615}
616
617/// Extension trait to convert ProcessRunner into a stream
618pub trait IntoStream {
619    /// Convert into an output stream
620    fn into_stream(self) -> OutputStream;
621}
622
623impl IntoStream for crate::ProcessRunner {
624    fn into_stream(self) -> OutputStream {
625        let streaming = StreamingRunner::new(self.command().to_string());
626        streaming.stream()
627    }
628}