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}