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, watch};
65use tokio::task::JoinHandle;
66
67use crate::signal::{
68    send_signal_to_process, signal_exit_code, Delivery, DEFAULT_KILL_GRACE_MS, DEFAULT_KILL_SIGNAL,
69};
70use crate::trace::trace_lazy;
71use crate::{CommandResult, Result};
72
73/// Default grace period (in milliseconds) to keep draining the stdio pipes
74/// after the process has exited before aborting any lingering readers. Mirrors
75/// the JavaScript `exitPumpGrace` default.
76const DEFAULT_EXIT_PUMP_GRACE_MS: u64 = 100;
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    kill_grace_ms: u64,
97    exit_pump_grace_ms: u64,
98}
99
100#[derive(Clone)]
101enum StreamingCommand {
102    Shell(String),
103    Argv {
104        program: OsString,
105        args: Vec<OsString>,
106    },
107}
108
109impl StreamingRunner {
110    /// Create a streaming runner for a command string interpreted by the
111    /// platform shell.
112    pub fn new(command: impl Into<String>) -> Self {
113        Self::with_command(StreamingCommand::Shell(command.into()))
114    }
115
116    /// Create a streaming runner for an executable and exact argument vector.
117    ///
118    /// Unlike [`StreamingRunner::new`], this constructor bypasses the platform
119    /// shell. Argument boundaries are therefore preserved on every platform,
120    /// including Windows, without requiring shell-specific quoting.
121    pub fn from_argv<P, I, S>(program: P, args: I) -> Self
122    where
123        P: Into<OsString>,
124        I: IntoIterator<Item = S>,
125        S: Into<OsString>,
126    {
127        Self::with_command(StreamingCommand::Argv {
128            program: program.into(),
129            args: args.into_iter().map(Into::into).collect(),
130        })
131    }
132
133    fn with_command(command: StreamingCommand) -> Self {
134        StreamingRunner {
135            command,
136            cwd: None,
137            env: None,
138            stdin_content: None,
139            kill_signal: DEFAULT_KILL_SIGNAL.to_string(),
140            kill_grace_ms: DEFAULT_KILL_GRACE_MS,
141            exit_pump_grace_ms: DEFAULT_EXIT_PUMP_GRACE_MS,
142        }
143    }
144
145    /// Set the working directory
146    pub fn cwd(mut self, path: impl Into<PathBuf>) -> Self {
147        self.cwd = Some(path.into());
148        self
149    }
150
151    /// Set environment variables
152    pub fn env(mut self, env: HashMap<String, String>) -> Self {
153        self.env = Some(env);
154        self
155    }
156
157    /// Set stdin content
158    pub fn stdin(mut self, content: impl Into<String>) -> Self {
159        self.stdin_content = Some(content.into());
160        self
161    }
162
163    /// Configure the signal used to stop the process when it is killed without
164    /// an explicit signal — i.e. [`OutputStream::kill`] or abandoning the
165    /// stream. Mirrors the JavaScript `killSignal` option (default `SIGTERM`).
166    ///
167    /// The reported exit code follows the conventional `128 + signal` mapping
168    /// (e.g. `SIGTERM` => 143, `SIGINT` => 130, `SIGKILL` => 137).
169    pub fn kill_signal(mut self, signal: impl Into<String>) -> Self {
170        self.kill_signal = signal.into();
171        self
172    }
173
174    /// Configure how long (in milliseconds) the child is given to handle the
175    /// kill signal before `SIGKILL` is sent. Mirrors the JavaScript `killGrace`
176    /// option (default 100ms).
177    ///
178    /// This is the window in which a child running its own `SIGTERM` handler
179    /// can shut down on its own terms. Set it to `0` to escalate immediately.
180    pub fn kill_grace_ms(mut self, ms: u64) -> Self {
181        self.kill_grace_ms = ms;
182        self
183    }
184
185    /// Configure the grace period (in milliseconds) to keep draining the stdio
186    /// pipes after the process exits before aborting lingering readers. Mirrors
187    /// the JavaScript `exitPumpGrace` option (default 100ms).
188    pub fn exit_pump_grace_ms(mut self, ms: u64) -> Self {
189        self.exit_pump_grace_ms = ms;
190        self
191    }
192
193    fn spawn(mut self) -> (OutputStream, JoinHandle<Result<()>>) {
194        let (tx, rx) = mpsc::channel(1024);
195        // Unbounded so a synchronous Drop can request a kill without awaiting.
196        let (kill_tx, kill_rx) = mpsc::unbounded_channel::<String>();
197        // The child is spawned inside the task below, so its id is not known
198        // when this returns. The task publishes it here as soon as the spawn
199        // succeeds; `OutputStream::pid` reads the latest value (issue #18).
200        let (pid_tx, pid_rx) = watch::channel(None);
201
202        // Spawn the process handling task
203        let command = self.command.clone();
204        let cwd = self.cwd.take();
205        let env = self.env.take();
206        let stdin_content = self.stdin_content.take();
207        let grace = GraceWindows {
208            exit_pump_ms: self.exit_pump_grace_ms,
209            kill_ms: self.kill_grace_ms,
210        };
211        let kill_signal = self.kill_signal.clone();
212
213        let task = tokio::spawn(async move {
214            let channels = StreamChannels {
215                output_tx: tx,
216                kill_rx,
217                pid_tx,
218            };
219            let result =
220                run_streaming_process(command, cwd, env, stdin_content, grace, channels).await;
221            if let Err(error) = &result {
222                trace_lazy("StreamingRunner", || format!("Error: {error}"));
223            }
224            result
225        });
226
227        (
228            OutputStream {
229                rx,
230                kill_tx,
231                kill_signal,
232                killed: false,
233                pid_rx,
234            },
235            task,
236        )
237    }
238
239    /// Start the process and return a stream of output chunks
240    pub fn stream(self) -> OutputStream {
241        self.spawn().0
242    }
243
244    /// Run to completion and collect all output
245    pub async fn collect(self) -> Result<CommandResult> {
246        let stdin_content = self.stdin_content.clone();
247        let mut stdout = Vec::new();
248        let mut stderr = Vec::new();
249        let mut exit_code = 0;
250
251        let (mut stream, task) = self.spawn();
252        while let Some(chunk) = stream.rx.recv().await {
253            match chunk {
254                OutputChunk::Stdout(data) => stdout.extend(data),
255                OutputChunk::Stderr(data) => stderr.extend(data),
256                OutputChunk::Exit(code) => exit_code = code,
257            }
258        }
259
260        task.await.map_err(|error| {
261            std::io::Error::other(format!("streaming process task failed: {error}"))
262        })??;
263
264        let mut result = CommandResult::new(
265            String::from_utf8_lossy(&stdout).to_string(),
266            String::from_utf8_lossy(&stderr).to_string(),
267            exit_code,
268        );
269        if let Some(content) = stdin_content {
270            result.stdin = crate::result_streams::CapturedInput::new(content.into_bytes());
271        }
272        Ok(result)
273    }
274
275    /// Run an exact-argument command to completion from synchronous code.
276    ///
277    /// Build the command with [`Self::from_argv`] and configure it with the
278    /// same `cwd`, `env`, and `stdin` methods used by [`Self::collect`]. Call
279    /// this outside a Tokio runtime; async callers should use `collect().await`.
280    pub fn collect_blocking(self) -> Result<CommandResult> {
281        if tokio::runtime::Handle::try_current().is_ok() {
282            return Err(std::io::Error::other(
283                "collect_blocking cannot run inside a Tokio runtime; use collect().await",
284            )
285            .into());
286        }
287        let runtime = tokio::runtime::Runtime::new()?;
288        runtime.block_on(self.collect())
289    }
290}
291
292/// Stream of output chunks from a process
293pub struct OutputStream {
294    rx: mpsc::Receiver<OutputChunk>,
295    kill_tx: mpsc::UnboundedSender<String>,
296    kill_signal: String,
297    killed: bool,
298    pid_rx: watch::Receiver<Option<u32>>,
299}
300
301impl OutputStream {
302    /// Receive the next chunk
303    pub async fn next(&mut self) -> Option<OutputChunk> {
304        self.rx.recv().await
305    }
306
307    /// Process id of the streamed command, as currently known.
308    ///
309    /// The child is spawned by a background task, so this is `None` for the
310    /// short window between [`StreamingRunner::stream`] returning and the spawn
311    /// completing, and stays `None` if the spawn failed. From the first
312    /// delivered chunk onwards it is set, and it remains readable after the
313    /// process has exited. Use [`wait_for_pid`](Self::wait_for_pid) to avoid
314    /// the startup window.
315    pub fn pid(&self) -> Option<u32> {
316        *self.pid_rx.borrow()
317    }
318
319    /// Process id of the streamed command, waiting for the spawn to complete.
320    ///
321    /// Resolves as soon as the child exists, and returns `None` if the process
322    /// could never be spawned. This is the streaming counterpart of awaiting a
323    /// stream before reading `runner.pid` in JavaScript.
324    pub async fn wait_for_pid(&mut self) -> Option<u32> {
325        // `wait_for` checks the current value first, so an already-published id
326        // returns without waiting. An error means the sending task is gone,
327        // which only happens when the spawn failed.
328        match self.pid_rx.wait_for(|pid| pid.is_some()).await {
329            Ok(pid) => *pid,
330            Err(_) => None,
331        }
332    }
333
334    /// Stop the process using the configured kill signal (default `SIGTERM`).
335    ///
336    /// This can be called from inside the consumption loop to stop a
337    /// long-running or endless process; a terminating `OutputChunk::Exit` is
338    /// still delivered afterwards.
339    pub fn kill(&mut self) {
340        let signal = self.kill_signal.clone();
341        self.kill_with(&signal);
342    }
343
344    /// Stop the process using an explicit signal, overriding the configured
345    /// kill signal for this call.
346    pub fn kill_with(&mut self, signal: &str) {
347        if self.killed {
348            return;
349        }
350        self.killed = true;
351        trace_lazy("OutputStream", || format!("kill | signal={}", signal));
352        // Best effort: the task may have already finished, in which case the
353        // receiver is gone and the send fails harmlessly.
354        let _ = self.kill_tx.send(signal.to_string());
355    }
356
357    /// Collect all remaining output into vectors
358    pub async fn collect(mut self) -> (Vec<u8>, Vec<u8>, i32) {
359        let mut stdout = Vec::new();
360        let mut stderr = Vec::new();
361        let mut exit_code = 0;
362
363        while let Some(chunk) = self.rx.recv().await {
364            match chunk {
365                OutputChunk::Stdout(data) => stdout.extend(data),
366                OutputChunk::Stderr(data) => stderr.extend(data),
367                OutputChunk::Exit(code) => exit_code = code,
368            }
369        }
370
371        (stdout, stderr, exit_code)
372    }
373
374    /// Collect stdout only, discarding stderr
375    pub async fn collect_stdout(mut self) -> Vec<u8> {
376        let mut stdout = Vec::new();
377
378        while let Some(chunk) = self.rx.recv().await {
379            if let OutputChunk::Stdout(data) = chunk {
380                stdout.extend(data);
381            }
382        }
383
384        stdout
385    }
386}
387
388impl Drop for OutputStream {
389    fn drop(&mut self) {
390        // Abandoning the stream (e.g. `break`-ing out of the loop) must stop the
391        // process, matching the JavaScript iterator's `finally` cleanup. If the
392        // process already finished this is a harmless no-op.
393        if !self.killed {
394            let _ = self.kill_tx.send(self.kill_signal.clone());
395        }
396    }
397}
398
399/// The channels `run_streaming_process` communicates over: output chunks out,
400/// kill requests in, and the child's id published once the spawn succeeds.
401struct StreamChannels {
402    /// Carries the output chunks, and finally the `Exit` chunk, to the consumer.
403    output_tx: mpsc::Sender<OutputChunk>,
404    /// Carries kill requests, by signal name, in from the consumer.
405    kill_rx: mpsc::UnboundedReceiver<String>,
406    /// Publishes the child's id, which is only known inside the spawning task.
407    pid_tx: watch::Sender<Option<u32>>,
408}
409
410/// How long the runner waits, in milliseconds, at the two points where it gives
411/// something a chance to finish on its own before forcing the issue.
412#[derive(Debug, Clone, Copy)]
413struct GraceWindows {
414    /// Time allowed for the readers to drain buffered output after the child
415    /// exits, before the `Exit` chunk is emitted.
416    exit_pump_ms: u64,
417    /// Time allowed for the child to handle the delivered signal, before the
418    /// escalation to `SIGKILL`.
419    kill_ms: u64,
420}
421
422/// Run a streaming process and send output to the channel
423async fn run_streaming_process(
424    command: StreamingCommand,
425    cwd: Option<PathBuf>,
426    env: Option<HashMap<String, String>>,
427    stdin_content: Option<String>,
428    grace: GraceWindows,
429    channels: StreamChannels,
430) -> Result<()> {
431    let StreamChannels {
432        output_tx: tx,
433        mut kill_rx,
434        pid_tx,
435    } = channels;
436    trace_lazy("StreamingRunner", || match &command {
437        StreamingCommand::Shell(command) => format!("Starting: {command}"),
438        StreamingCommand::Argv { program, args } => {
439            format!("Starting argv command: {program:?} {args:?}")
440        }
441    });
442
443    let mut cmd = match command {
444        StreamingCommand::Shell(command) => crate::utils::shell_command(&command, env.as_ref()),
445        StreamingCommand::Argv { program, args } => {
446            let mut cmd = Command::new(program);
447            cmd.args(args);
448            cmd
449        }
450    };
451
452    // Configure stdio
453    if stdin_content.is_some() {
454        cmd.stdin(Stdio::piped());
455    } else {
456        cmd.stdin(Stdio::null());
457    }
458    cmd.stdout(Stdio::piped());
459    cmd.stderr(Stdio::piped());
460
461    // Run the child in its own process group so we can signal the whole group
462    // (parent + grandchildren), matching the JavaScript implementation.
463    #[cfg(unix)]
464    cmd.process_group(0);
465
466    // Set working directory
467    if let Some(ref cwd) = cwd {
468        cmd.current_dir(cwd);
469    }
470
471    // Set environment
472    if let Some(ref env_vars) = env {
473        for (key, value) in env_vars {
474            cmd.env(key, value);
475        }
476    }
477
478    let mut child = cmd.spawn()?;
479    // Publish the id before any awaiting, so a consumer asking for it as soon
480    // as the first chunk arrives already sees it.
481    let _ = pid_tx.send(child.id());
482
483    // Write stdin if needed
484    if let Some(content) = stdin_content {
485        if let Some(mut stdin) = child.stdin.take() {
486            use tokio::io::AsyncWriteExt;
487            let _ = stdin.write_all(content.as_bytes()).await;
488            let _ = stdin.shutdown().await;
489        }
490    }
491
492    // Spawn stdout reader
493    let stdout = child.stdout.take();
494    let tx_stdout = tx.clone();
495    let stdout_handle = stdout.map(|stdout| {
496        tokio::spawn(async move {
497            let mut reader = BufReader::new(stdout);
498            let mut buf = vec![0u8; 8192];
499            loop {
500                use tokio::io::AsyncReadExt;
501                match reader.read(&mut buf).await {
502                    Ok(0) => break,
503                    Ok(n) => {
504                        if tx_stdout
505                            .send(OutputChunk::Stdout(buf[..n].to_vec()))
506                            .await
507                            .is_err()
508                        {
509                            break;
510                        }
511                    }
512                    Err(_) => break,
513                }
514            }
515        })
516    });
517
518    // Spawn stderr reader
519    let stderr = child.stderr.take();
520    let tx_stderr = tx.clone();
521    let stderr_handle = stderr.map(|stderr| {
522        tokio::spawn(async move {
523            let mut reader = BufReader::new(stderr);
524            let mut buf = vec![0u8; 8192];
525            loop {
526                use tokio::io::AsyncReadExt;
527                match reader.read(&mut buf).await {
528                    Ok(0) => break,
529                    Ok(n) => {
530                        if tx_stderr
531                            .send(OutputChunk::Stderr(buf[..n].to_vec()))
532                            .await
533                            .is_err()
534                        {
535                            break;
536                        }
537                    }
538                    Err(_) => break,
539                }
540            }
541        })
542    });
543
544    // Wait for the process to exit OR for a kill request — crucially we do NOT
545    // wait for the readers first. If a grandchild keeps the pipe open the
546    // readers would never finish, so waiting on them before the exit (as the
547    // old implementation did) would hang forever (issue #155).
548    let pid = child.id();
549    let code;
550    tokio::select! {
551        status = child.wait() => {
552            code = status_to_code(status?);
553        }
554        maybe_signal = kill_rx.recv() => {
555            // A kill was requested (explicit kill()/kill_with() or the stream
556            // being dropped). Stop the process group with the requested signal.
557            let signal = maybe_signal.unwrap_or_else(|| DEFAULT_KILL_SIGNAL.to_string());
558            trace_lazy("StreamingRunner", || format!("Kill requested | signal={}", signal));
559            // Give the child its grace period to run its own handler and exit
560            // on its own terms, then escalate to a forceful kill so a process
561            // that ignores the signal still terminates.
562            //
563            // A zero grace period means the child is given no opportunity to
564            // handle the signal, so the requested signal is not delivered at
565            // all. Anything done between it and the forceful kill - a syscall,
566            // or awaiting a zero-length timeout, which yields to the runtime -
567            // is a window the child can be scheduled in, which made "no grace"
568            // a race the child occasionally won rather than a guarantee.
569            let survived_grace = if grace.kill_ms == 0 {
570                true
571            } else {
572                if let Some(pid) = pid {
573                    // The child is always spawned with `process_group(0)`
574                    // above, so it leads the group named by its own pid.
575                    send_signal_to_process(pid, &signal, Delivery::ProcessAndGroup);
576                }
577                tokio::time::timeout(Duration::from_millis(grace.kill_ms), child.wait())
578                    .await
579                    .is_err()
580            };
581            if survived_grace {
582                if let Some(pid) = pid {
583                    send_signal_to_process(pid, "SIGKILL", Delivery::ProcessAndGroup);
584                }
585                let _ = child.start_kill();
586                let _ = child.wait().await;
587            }
588            // Report the conventional 128 + signal code for the requested
589            // signal, matching the JavaScript implementation.
590            code = signal_exit_code(&signal);
591        }
592    }
593
594    // The process has exited. Give the readers a short grace period to flush any
595    // buffered output, then abort any that are still blocked on an inherited
596    // open pipe so we don't hang.
597    let stdout_abort = stdout_handle.as_ref().map(|h| h.abort_handle());
598    let stderr_abort = stderr_handle.as_ref().map(|h| h.abort_handle());
599    let drain = async {
600        if let Some(handle) = stdout_handle {
601            let _ = handle.await;
602        }
603        if let Some(handle) = stderr_handle {
604            let _ = handle.await;
605        }
606    };
607    if tokio::time::timeout(Duration::from_millis(grace.exit_pump_ms), drain)
608        .await
609        .is_err()
610    {
611        // A reader is still blocked on an inherited open pipe — abort it so the
612        // exit chunk is delivered without waiting for the grandchild.
613        if let Some(abort) = stdout_abort {
614            abort.abort();
615        }
616        if let Some(abort) = stderr_abort {
617            abort.abort();
618        }
619    }
620
621    // Send exit code (always — even if a reader was aborted).
622    let _ = tx.send(OutputChunk::Exit(code)).await;
623
624    trace_lazy("StreamingRunner", || format!("Exited with code: {}", code));
625
626    Ok(())
627}
628
629/// Convert an exit status into a numeric exit code, using the conventional
630/// `128 + signal` mapping when the process was terminated by a signal.
631fn status_to_code(status: std::process::ExitStatus) -> i32 {
632    if let Some(code) = status.code() {
633        return code;
634    }
635    #[cfg(unix)]
636    {
637        use std::os::unix::process::ExitStatusExt;
638        if let Some(sig) = status.signal() {
639            return 128 + sig;
640        }
641    }
642    -1
643}
644
645/// Async iterator trait for output streams
646#[async_trait::async_trait]
647pub trait AsyncIterator {
648    type Item;
649
650    /// Get the next item from the iterator
651    async fn next(&mut self) -> Option<Self::Item>;
652}
653
654#[async_trait::async_trait]
655impl AsyncIterator for OutputStream {
656    type Item = OutputChunk;
657
658    async fn next(&mut self) -> Option<Self::Item> {
659        self.rx.recv().await
660    }
661}
662
663/// Extension trait to convert ProcessRunner into a stream
664pub trait IntoStream {
665    /// Convert into an output stream
666    fn into_stream(self) -> OutputStream;
667}
668
669impl IntoStream for crate::ProcessRunner {
670    fn into_stream(self) -> OutputStream {
671        let streaming = StreamingRunner::new(self.command().to_string());
672        streaming.stream()
673    }
674}