Skip to main content

rightkit_child_process/run/
streaming.rs

1//! [`run_streaming`]: the cancel-aware streaming runner that apps used to hand-roll around
2//! [`OwnedCommand`](crate::OwnedCommand). Output is shown while the child runs, the caller can
3//! cancel, and nothing is retained except bounded tails.
4
5use super::{StdinWriter, DRAIN_WAIT};
6use crate::{NativeOutputCapture, OwnedCommand, ReapOutcome};
7use std::{
8    io,
9    process::ExitStatus,
10    sync::{
11        atomic::{AtomicBool, Ordering},
12        mpsc::{self, RecvTimeoutError, SyncSender, TrySendError},
13    },
14    time::{Duration, Instant},
15};
16
17/// How long the killed tree's leader gets to be reaped before the call fails with `TimedOut`
18/// (a process in an uninterruptible state cannot be reaped by any means).
19const KILL_REAP_WAIT: Duration = Duration::from_secs(5);
20
21/// The longest a loop iteration waits for output before it looks at the child, the cancel
22/// predicate and the deadline again. `StreamOptions::poll` shortens it, never lengthens it.
23const MAX_STEP: Duration = Duration::from_millis(25);
24
25/// Chunks that may be queued between the reader threads and the calling thread. A caller whose
26/// callbacks are slower than the child makes the child wait on the pipe (back-pressure), not
27/// the process grow.
28const QUEUE_CHUNKS: usize = 64;
29
30/// A stderr line longer than this is not delivered: one marker line,
31/// `[stderr line over N bytes dropped]`, takes its place (the rule of
32/// [`StderrTail`](crate::StderrTail), with a fixed limit of four times its default 64 KiB tail).
33const LINE_CAP: usize = 256 * 1024;
34
35/// Limits and options for [`run_streaming`]. Build one with [`StreamOptions::new`] (or
36/// `Default`) and the builder methods; the struct is `#[non_exhaustive]`, so later options do
37/// not break callers.
38#[derive(Clone, Debug, Eq, PartialEq)]
39#[non_exhaustive]
40pub struct StreamOptions {
41    /// How long the program may run before its tree is terminated. `None` (the default) is no
42    /// deadline: only the child's exit or the cancel predicate ends the call.
43    pub timeout: Option<Duration>,
44    /// The longest the call goes without evaluating the cancel predicate and the deadline
45    /// (default 100 ms). The predicate is evaluated at least this often, however much output
46    /// is flowing. Values under 1 ms behave as 1 ms.
47    pub poll: Duration,
48    /// Most bytes of stdout kept in [`StreamOutput::stdout_tail`]: the last ones (default
49    /// 64 KiB).
50    pub stdout_tail_cap: usize,
51    /// Most bytes of stderr kept in [`StreamOutput::stderr_tail`]: the last ones (default
52    /// 64 KiB).
53    pub stderr_tail_cap: usize,
54    /// Hide the console window on Windows. The default. `false` leaves it visible.
55    pub no_window: bool,
56    /// Bytes written to the child's stdin, then EOF. `None` (the default) gives it a null
57    /// stdin.
58    pub input: Option<Vec<u8>>,
59}
60
61impl Default for StreamOptions {
62    fn default() -> Self {
63        Self {
64            timeout: None,
65            poll: Duration::from_millis(100),
66            stdout_tail_cap: 64 * 1024,
67            stderr_tail_cap: 64 * 1024,
68            no_window: true,
69            input: None,
70        }
71    }
72}
73
74impl StreamOptions {
75    /// No deadline, a 100 ms poll, 64 KiB tails, no window, null stdin.
76    pub fn new() -> Self {
77        Self::default()
78    }
79
80    pub fn timeout(mut self, timeout: Duration) -> Self {
81        self.timeout = Some(timeout);
82        self
83    }
84
85    pub fn poll(mut self, poll: Duration) -> Self {
86        self.poll = poll;
87        self
88    }
89
90    pub fn stdout_tail_cap(mut self, bytes: usize) -> Self {
91        self.stdout_tail_cap = bytes;
92        self
93    }
94
95    pub fn stderr_tail_cap(mut self, bytes: usize) -> Self {
96        self.stderr_tail_cap = bytes;
97        self
98    }
99
100    pub fn no_window(mut self, hidden: bool) -> Self {
101        self.no_window = hidden;
102        self
103    }
104
105    pub fn input(mut self, bytes: impl Into<Vec<u8>>) -> Self {
106        self.input = Some(bytes.into());
107        self
108    }
109}
110
111/// What [`run_streaming`] observed.
112#[derive(Clone, Debug, Eq, PartialEq)]
113#[non_exhaustive]
114pub struct StreamOutput {
115    /// The program's exit status. When `cancelled` or `timed_out` is true, the status of the
116    /// terminated tree's leader (on Unix, usually a signal).
117    pub status: ExitStatus,
118    /// True when the cancel predicate returned true and the tree was terminated.
119    pub cancelled: bool,
120    /// True when the deadline passed and the tree was terminated. Never set together with
121    /// `cancelled`: the predicate is evaluated first.
122    pub timed_out: bool,
123    /// The last `stdout_tail_cap` bytes of stdout, exactly as the child wrote them.
124    pub stdout_tail: Vec<u8>,
125    /// The last lines of stderr, joined by `\n` (no trailing newline), at most
126    /// `stderr_tail_cap` bytes; invalid UTF-8 is replaced. A cut that falls inside a
127    /// multi-byte character drops the partial character.
128    pub stderr_tail: String,
129}
130
131/// Runs `cmd` and shows its output while it runs.
132///
133/// The caller configures `cmd` (program, arguments, environment, working directory); the crate
134/// sets stdin, stdout and stderr (pipes; stdin is null unless `options.input` is set) and the
135/// window mode from `options.no_window`, as [`run_command`](crate::run_command) does (this
136/// includes the stdio of a Windows `WindowsSpawnConfig` on an `inherit_handles_only` command). The child
137/// is owned: its whole tree is terminated on cancel or timeout, not only the leader.
138///
139/// Reader threads read the child's stdout and stderr and queue what they read; the callbacks
140/// run on the calling thread, in arrival order:
141/// - `on_stdout` receives raw chunks, byte-exact (a `\r` progress rewrite arrives as the
142///   `\r` it is). Chunk boundaries are arbitrary: do not assume a chunk is a line or a
143///   character.
144/// - `on_stderr_line` receives stderr split into lines the way [`StderrTail`](crate::StderrTail)
145///   splits them: a line ends at `\n`, at `\r`, or at `\r\n` as one terminator, so each
146///   progress rewrite is a line; the terminator is not included; empty lines are delivered; an
147///   unterminated last line is delivered at the end of the stream. Invalid UTF-8 is replaced.
148///   A line over 256 KiB is replaced by one `[stderr line over 262144 bytes dropped]` line.
149///
150/// `cancel` is evaluated on the calling thread at least every `options.poll` and before the
151/// first output is waited for; callbacks and `cancel` may share state through `Cell`s or
152/// atomics. When it returns true, or when `options.timeout` passes, the tree is terminated
153/// ([`OwnedChild::terminate_tree_bounded`](crate::OwnedChild::terminate_tree_bounded)), the
154/// leader is reaped, and the output still queued is delivered; the call then returns `Ok` with
155/// `cancelled` or `timed_out` set, not an error. A child that exits on its own is not signalled
156/// (its leftover tree is, as for [`run`](crate::run)).
157///
158/// A slow callback slows the child down (back-pressure on its pipe; at most 64 chunks are
159/// queued) and delays the next `cancel` evaluation by as long as it runs.
160///
161/// Nothing waits without a bound. After the child is gone the readers get 2 s to reach the end
162/// of their streams (a grandchild that escaped the tree and kept a pipe open cannot extend it);
163/// they are then stopped and joined, and what they had queued is still delivered. Tails keep
164/// only the last bytes, so memory stays bounded however much the child writes.
165///
166/// Stdin input uses the same bounded writer as [`run_with_stdin`](crate::run_with_stdin): it is
167/// written on its own thread while output is drained, EOF follows, and a child that exits
168/// without reading it is a normal early exit. Any other stdin write failure is returned as
169/// `Err` after the tree is down. A process that outlived the tree and still holds stdin without
170/// reading it cannot hold the call past its deadline.
171///
172/// What a return guarantees about the writer:
173/// - Every `Ok` return: the writer thread has been joined and the write end is closed.
174/// - A writer that cannot be stopped within its bound makes the call fail with
175///   `ErrorKind::TimedOut`.
176/// - Any other `Err` (an early failure, such as starting a reader or waiting on the child)
177///   still cancels the writer with the same bound before returning, but reports that original
178///   error, not the cleanup outcome.
179/// - Unix, every return: the parent has closed the write end, so a writer thread that is
180///   still alive can deliver nothing more and ends on its next step.
181/// - Windows, after `TimedOut` or an early `Err`: a writer that did not stop within the bound
182///   may still be alive and blocked in a write, owning the write end, with its cancel flag set.
183///   This needs a pending `CancelIoEx` that fails to abort the write, which is not expected.
184///
185/// Fails when the program, a reader or the writer cannot start, when waiting on the child
186/// fails, or with `ErrorKind::TimedOut` when the stdin writer cannot be stopped or the
187/// terminated leader is not reaped within 5 s (a callback panic unwinds with the same cleanup).
188/// A pipe read failure on either stream is also an `Err` (with the failing stream named), never
189/// an `Ok` with truncated tails. A non-zero exit is a normal `Ok` with `status` set.
190pub fn run_streaming(
191    mut cmd: OwnedCommand,
192    options: &StreamOptions,
193    cancel: &dyn Fn() -> bool,
194    on_stdout: &mut dyn FnMut(&[u8]),
195    on_stderr_line: &mut dyn FnMut(&str),
196) -> io::Result<StreamOutput> {
197    // Recorded, not applied to `Command` here: `spawn` applies it to the std `Command` and to a
198    // native Windows spawn config alike, after every caller edit.
199    cmd.pipe_stdio(options.input.is_some());
200    if options.no_window {
201        cmd.windows_hide();
202    } else {
203        cmd.windows_visible();
204    }
205    let mut child = cmd.spawn()?;
206    // Declared before the readers so an early `?` return drops (and so stops) them first; the
207    // writer's `Drop` cancels it with its bound, and the child's `Drop` kills the tree.
208    let writer = match (options.input.as_deref(), child.take_stdin()) {
209        (Some(bytes), Some(stdin)) => Some(StdinWriter::spawn(stdin, bytes.to_vec())?),
210        _ => None,
211    };
212    let (sender, receiver) = mpsc::sync_channel::<Message>(QUEUE_CHUNKS);
213    let mut stdout = child
214        .take_stdout()
215        .map(|pipe| {
216            NativeOutputCapture::stdout_streaming(pipe, forward(sender.clone(), Message::Stdout))
217        })
218        .transpose()?;
219    let mut stderr = child
220        .take_stderr()
221        .map(|pipe| {
222            NativeOutputCapture::stderr_streaming(pipe, forward(sender.clone(), Message::Stderr))
223        })
224        .transpose()?;
225    // Only the readers' sinks keep the channel open: it disconnects when both are gone.
226    drop(sender);
227
228    let mut sinks = Sinks {
229        on_stdout,
230        on_stderr_line,
231        stdout_tail: Tail::new(options.stdout_tail_cap),
232        stderr_tail: Tail::new(options.stderr_tail_cap),
233        splitter: LineSplitter::default(),
234    };
235    let poll = options.poll.max(Duration::from_millis(1));
236    let step = poll.min(MAX_STEP);
237    let started = Instant::now();
238    let deadline = options.timeout.and_then(|limit| started.checked_add(limit));
239    let mut last_cancel: Option<Instant> = None;
240    let mut last_exit_look = started;
241    let mut streams_done = false;
242    let ended = loop {
243        let now = Instant::now();
244        if last_cancel.is_none_or(|at| now.duration_since(at) >= poll) {
245            last_cancel = Some(now);
246            if cancel() {
247                break Ended::Cancelled;
248            }
249        }
250        if deadline.is_some_and(|at| now >= at) {
251            break Ended::TimedOut;
252        }
253        if streams_done {
254            // Nothing more to read, so the wait for the exit is the pause.
255            if let Some(status) = child.wait_exit_then_sweep(step)? {
256                break Ended::Exited(status);
257            }
258            continue;
259        }
260        match receiver.recv_timeout(step) {
261            Ok(message) => sinks.handle(message),
262            Err(RecvTimeoutError::Timeout) => {}
263            Err(RecvTimeoutError::Disconnected) => {
264                streams_done = true;
265                continue;
266            }
267        }
268        if last_exit_look.elapsed() >= step {
269            last_exit_look = Instant::now();
270            if let Some(status) = child.wait_exit_then_sweep(Duration::ZERO)? {
271                break Ended::Exited(status);
272            }
273        }
274    };
275
276    let (status, cancelled, timed_out) = match ended {
277        Ended::Exited(status) => (Some(status), false, false),
278        Ended::Cancelled | Ended::TimedOut => {
279            let reaped = match child.terminate_tree_bounded(KILL_REAP_WAIT)? {
280                ReapOutcome::Exited(status) => Some(status),
281                ReapOutcome::TimedOut => None,
282            };
283            (
284                reaped,
285                matches!(ended, Ended::Cancelled),
286                matches!(ended, Ended::TimedOut),
287            )
288        }
289    };
290
291    // The child is gone. Let the readers reach the end of their streams, but never wait longer
292    // than DRAIN_WAIT: a process that escaped the tree can keep a pipe open.
293    let drain_end = Instant::now() + DRAIN_WAIT;
294    while !streams_done {
295        let left = drain_end.saturating_duration_since(Instant::now());
296        if left.is_zero() {
297            break;
298        }
299        match receiver.recv_timeout(left) {
300            Ok(message) => sinks.handle(message),
301            Err(RecvTimeoutError::Timeout) => {}
302            Err(RecvTimeoutError::Disconnected) => streams_done = true,
303        }
304    }
305    // Stop and join both readers (each wakes at once and gives up a pending send), then deliver
306    // what they had queued.
307    if let Some(capture) = stdout.as_mut() {
308        let _ = capture.cancel();
309    }
310    if let Some(capture) = stderr.as_mut() {
311        let _ = capture.cancel();
312    }
313    for message in receiver.try_iter() {
314        sinks.handle(message);
315    }
316    sinks.finish_stream();
317    let writer_result = writer.map(StdinWriter::finish).transpose();
318    // Both readers are joined, so a read or shutdown failure they stored is final. Taken for
319    // both streams before any return: the delivered output is incomplete, so it is an error.
320    let stdout_failure = super::capture_failure("stdout", stdout.as_ref());
321    let stderr_failure = super::capture_failure("stderr", stderr.as_ref());
322    stdout_failure?;
323    stderr_failure?;
324    writer_result?;
325    let Some(status) = status else {
326        return Err(io::Error::new(
327            io::ErrorKind::TimedOut,
328            "the terminated process was not reaped within its bound",
329        ));
330    };
331    Ok(StreamOutput {
332        status,
333        cancelled,
334        timed_out,
335        stdout_tail: sinks.stdout_tail.into_bytes(),
336        stderr_tail: sinks.stderr_tail.into_text(),
337    })
338}
339
340#[derive(Clone, Copy)]
341enum Ended {
342    Exited(ExitStatus),
343    Cancelled,
344    TimedOut,
345}
346
347enum Message {
348    Stdout(Vec<u8>),
349    Stderr(Vec<u8>),
350}
351
352/// A reader-thread sink that queues each chunk. A full queue is waited out in short sleeps and
353/// given up once the capture is stopped, so stopping a reader never waits on the consumer.
354fn forward(
355    sender: SyncSender<Message>,
356    wrap: fn(Vec<u8>) -> Message,
357) -> crate::native_capture::ChunkSink {
358    Box::new(move |chunk: &[u8], stop: &AtomicBool| {
359        let mut message = wrap(chunk.to_vec());
360        loop {
361            match sender.try_send(message) {
362                Ok(()) | Err(TrySendError::Disconnected(_)) => return,
363                Err(TrySendError::Full(back)) => {
364                    if stop.load(Ordering::Acquire) {
365                        return;
366                    }
367                    message = back;
368                    std::thread::sleep(Duration::from_millis(2));
369                }
370            }
371        }
372    })
373}
374
375/// The calling thread's side: callbacks plus the bounded tails.
376struct Sinks<'a> {
377    on_stdout: &'a mut dyn FnMut(&[u8]),
378    on_stderr_line: &'a mut dyn FnMut(&str),
379    stdout_tail: Tail,
380    stderr_tail: Tail,
381    splitter: LineSplitter,
382}
383
384impl Sinks<'_> {
385    fn handle(&mut self, message: Message) {
386        match message {
387            Message::Stdout(chunk) => {
388                self.stdout_tail.push(&chunk);
389                (self.on_stdout)(&chunk);
390            }
391            Message::Stderr(chunk) => {
392                let Self {
393                    on_stderr_line,
394                    stderr_tail,
395                    splitter,
396                    ..
397                } = self;
398                splitter.feed(&chunk, &mut |line| {
399                    stderr_tail.push_line(line);
400                    (**on_stderr_line)(line);
401                });
402            }
403        }
404    }
405
406    /// Delivers the unterminated last stderr line, if any.
407    fn finish_stream(&mut self) {
408        let Self {
409            on_stderr_line,
410            stderr_tail,
411            splitter,
412            ..
413        } = self;
414        splitter.finish(&mut |line| {
415            stderr_tail.push_line(line);
416            (**on_stderr_line)(line);
417        });
418    }
419}
420
421/// The last `cap` bytes written to it. Holds at most `2 * cap` bytes plus one chunk.
422struct Tail {
423    cap: usize,
424    bytes: Vec<u8>,
425}
426
427impl Tail {
428    fn new(cap: usize) -> Self {
429        Self {
430            cap,
431            bytes: Vec::new(),
432        }
433    }
434
435    fn push(&mut self, chunk: &[u8]) {
436        if self.cap == 0 {
437            return;
438        }
439        if chunk.len() >= self.cap {
440            self.bytes.clear();
441            self.bytes
442                .extend_from_slice(&chunk[chunk.len() - self.cap..]);
443            return;
444        }
445        self.bytes.extend_from_slice(chunk);
446        if self.bytes.len() > self.cap.saturating_mul(2) {
447            self.trim();
448        }
449    }
450
451    /// One stderr line and the newline that separates it from the next.
452    fn push_line(&mut self, line: &str) {
453        self.push(line.as_bytes());
454        self.push(b"\n");
455    }
456
457    fn trim(&mut self) {
458        if self.bytes.len() > self.cap {
459            let cut = self.bytes.len() - self.cap;
460            self.bytes.drain(..cut);
461        }
462    }
463
464    fn into_bytes(mut self) -> Vec<u8> {
465        self.trim();
466        self.bytes
467    }
468
469    /// The tail as the stderr text: lines joined by `\n`, without the final newline, and
470    /// without a character the cut split.
471    fn into_text(self) -> String {
472        let bytes = self.into_bytes();
473        let start = bytes
474            .iter()
475            .position(|&byte| byte & 0xC0 != 0x80)
476            .unwrap_or(bytes.len());
477        let mut text = String::from_utf8_lossy(&bytes[start..]).into_owned();
478        if text.ends_with('\n') {
479            text.pop();
480        }
481        text
482    }
483}
484
485/// Splits stderr bytes into lines: `\n`, `\r` and `\r\n` end a line.
486#[derive(Default)]
487struct LineSplitter {
488    line: Vec<u8>,
489    /// The last byte was `\r`, so a `\n` next belongs to the same terminator.
490    skip_lf: bool,
491    /// The current line passed [`LINE_CAP`]; the rest of it is discarded.
492    dropping: bool,
493}
494
495impl LineSplitter {
496    fn feed(&mut self, bytes: &[u8], emit: &mut dyn FnMut(&str)) {
497        for &byte in bytes {
498            if std::mem::take(&mut self.skip_lf) && byte == b'\n' {
499                continue;
500            }
501            match byte {
502                b'\n' | b'\r' => {
503                    self.skip_lf = byte == b'\r';
504                    self.end_line(emit);
505                }
506                _ if self.dropping => {}
507                _ => {
508                    self.line.push(byte);
509                    if self.line.len() > LINE_CAP {
510                        self.line.clear();
511                        self.dropping = true;
512                    }
513                }
514            }
515        }
516    }
517
518    /// The stream ended: an unterminated last line is still a line.
519    fn finish(&mut self, emit: &mut dyn FnMut(&str)) {
520        if !self.line.is_empty() || self.dropping {
521            self.end_line(emit);
522        }
523    }
524
525    fn end_line(&mut self, emit: &mut dyn FnMut(&str)) {
526        if self.dropping {
527            self.dropping = false;
528            emit(&format!("[stderr line over {LINE_CAP} bytes dropped]"));
529        } else {
530            let text = String::from_utf8_lossy(&self.line).into_owned();
531            emit(&text);
532        }
533        self.line.clear();
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    fn lines(chunks: &[&[u8]]) -> Vec<String> {
542        let mut splitter = LineSplitter::default();
543        let mut out = Vec::new();
544        for chunk in chunks {
545            splitter.feed(chunk, &mut |line| out.push(line.to_owned()));
546        }
547        splitter.finish(&mut |line| out.push(line.to_owned()));
548        out
549    }
550
551    #[test]
552    fn lines_end_at_lf_cr_and_crlf_across_chunks() {
553        assert_eq!(lines(&[b"a\rb\nc\r\nd"]), ["a", "b", "c", "d"]);
554        // CRLF split across two reads is still one terminator.
555        assert_eq!(lines(&[b"x\r", b"\ny\n\nz"]), ["x", "y", "", "z"]);
556        assert_eq!(lines(&[b"par", b"tial\n"]), ["partial"]);
557        assert!(lines(&[b""]).is_empty());
558    }
559
560    #[test]
561    fn an_overlong_line_becomes_one_marker() {
562        let long = vec![b'x'; LINE_CAP + 10];
563        let got = lines(&[&long, b"\nnext\n"]);
564        let marker = format!("[stderr line over {LINE_CAP} bytes dropped]");
565        assert_eq!(got, [marker.as_str(), "next"]);
566    }
567
568    #[test]
569    fn a_tail_keeps_the_last_bytes() {
570        let mut tail = Tail::new(5);
571        tail.push(b"ab");
572        tail.push(b"cdefgh");
573        tail.push(b"i");
574        assert_eq!(tail.into_bytes(), b"efghi");
575        let mut none = Tail::new(0);
576        none.push(b"abc");
577        assert!(none.into_bytes().is_empty());
578        // Many small pushes stay bounded and keep the end.
579        let mut many = Tail::new(4);
580        for byte in 0..100u8 {
581            many.push(&[byte]);
582            assert!(many.bytes.len() <= 9);
583        }
584        assert_eq!(many.into_bytes(), [96, 97, 98, 99]);
585    }
586
587    #[test]
588    fn the_stderr_tail_is_joined_lines_without_a_split_character() {
589        let mut tail = Tail::new(6);
590        for line in ["l1", "l2", "l3", "l4"] {
591            tail.push_line(line);
592        }
593        assert_eq!(tail.into_text(), "l3\nl4");
594        // A cut inside a two-byte character drops the rest of that character.
595        let mut tail = Tail::new(4);
596        tail.push_line("a\u{e9}\u{e9}");
597        assert_eq!(tail.into_text(), "\u{e9}");
598    }
599
600    #[test]
601    fn options_default_and_build() {
602        let options = StreamOptions::new();
603        assert_eq!(options.timeout, None);
604        assert_eq!(options.poll, Duration::from_millis(100));
605        assert_eq!(options.stdout_tail_cap, 64 * 1024);
606        assert_eq!(options.stderr_tail_cap, 64 * 1024);
607        assert!(options.no_window);
608        assert!(options.input.is_none());
609        let options = options
610            .timeout(Duration::from_secs(3))
611            .poll(Duration::from_millis(10))
612            .stdout_tail_cap(1)
613            .stderr_tail_cap(2)
614            .no_window(false)
615            .input(b"in".to_vec());
616        assert_eq!(options.timeout, Some(Duration::from_secs(3)));
617        assert_eq!(options.poll, Duration::from_millis(10));
618        assert_eq!((options.stdout_tail_cap, options.stderr_tail_cap), (1, 2));
619        assert!(!options.no_window);
620        assert_eq!(options.input.as_deref(), Some(&b"in"[..]));
621    }
622}