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