use super::{StdinWriter, DRAIN_WAIT};
use crate::{NativeOutputCapture, OwnedCommand, ReapOutcome};
use std::{
io,
process::ExitStatus,
sync::{
atomic::{AtomicBool, Ordering},
mpsc::{self, RecvTimeoutError, SyncSender, TrySendError},
},
time::{Duration, Instant},
};
const KILL_REAP_WAIT: Duration = Duration::from_secs(5);
const MAX_STEP: Duration = Duration::from_millis(25);
const QUEUE_CHUNKS: usize = 64;
const LINE_CAP: usize = 256 * 1024;
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct StreamOptions {
pub timeout: Option<Duration>,
pub poll: Duration,
pub stdout_tail_cap: usize,
pub stderr_tail_cap: usize,
pub no_window: bool,
pub input: Option<Vec<u8>>,
}
impl Default for StreamOptions {
fn default() -> Self {
Self {
timeout: None,
poll: Duration::from_millis(100),
stdout_tail_cap: 64 * 1024,
stderr_tail_cap: 64 * 1024,
no_window: true,
input: None,
}
}
}
impl StreamOptions {
pub fn new() -> Self {
Self::default()
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn poll(mut self, poll: Duration) -> Self {
self.poll = poll;
self
}
pub fn stdout_tail_cap(mut self, bytes: usize) -> Self {
self.stdout_tail_cap = bytes;
self
}
pub fn stderr_tail_cap(mut self, bytes: usize) -> Self {
self.stderr_tail_cap = bytes;
self
}
pub fn no_window(mut self, hidden: bool) -> Self {
self.no_window = hidden;
self
}
pub fn input(mut self, bytes: impl Into<Vec<u8>>) -> Self {
self.input = Some(bytes.into());
self
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct StreamOutput {
pub status: ExitStatus,
pub cancelled: bool,
pub timed_out: bool,
pub stdout_tail: Vec<u8>,
pub stderr_tail: String,
}
pub fn run_streaming(
mut cmd: OwnedCommand,
options: &StreamOptions,
cancel: &dyn Fn() -> bool,
on_stdout: &mut dyn FnMut(&[u8]),
on_stderr_line: &mut dyn FnMut(&str),
) -> io::Result<StreamOutput> {
cmd.pipe_stdio(options.input.is_some());
if options.no_window {
cmd.windows_hide();
} else {
cmd.windows_visible();
}
let mut child = cmd.spawn()?;
let writer = match (options.input.as_deref(), child.take_stdin()) {
(Some(bytes), Some(stdin)) => Some(StdinWriter::spawn(stdin, bytes.to_vec())?),
_ => None,
};
let (sender, receiver) = mpsc::sync_channel::<Message>(QUEUE_CHUNKS);
let mut stdout = child
.take_stdout()
.map(|pipe| {
NativeOutputCapture::stdout_streaming(pipe, forward(sender.clone(), Message::Stdout))
})
.transpose()?;
let mut stderr = child
.take_stderr()
.map(|pipe| {
NativeOutputCapture::stderr_streaming(pipe, forward(sender.clone(), Message::Stderr))
})
.transpose()?;
drop(sender);
let mut sinks = Sinks {
on_stdout,
on_stderr_line,
stdout_tail: Tail::new(options.stdout_tail_cap),
stderr_tail: Tail::new(options.stderr_tail_cap),
splitter: LineSplitter::default(),
};
let poll = options.poll.max(Duration::from_millis(1));
let step = poll.min(MAX_STEP);
let started = Instant::now();
let deadline = options.timeout.and_then(|limit| started.checked_add(limit));
let mut last_cancel: Option<Instant> = None;
let mut last_exit_look = started;
let mut streams_done = false;
let ended = loop {
let now = Instant::now();
if last_cancel.is_none_or(|at| now.duration_since(at) >= poll) {
last_cancel = Some(now);
if cancel() {
break Ended::Cancelled;
}
}
if deadline.is_some_and(|at| now >= at) {
break Ended::TimedOut;
}
if streams_done {
if let Some(status) = child.wait_exit_then_sweep(step)? {
break Ended::Exited(status);
}
continue;
}
match receiver.recv_timeout(step) {
Ok(message) => sinks.handle(message),
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => {
streams_done = true;
continue;
}
}
if last_exit_look.elapsed() >= step {
last_exit_look = Instant::now();
if let Some(status) = child.wait_exit_then_sweep(Duration::ZERO)? {
break Ended::Exited(status);
}
}
};
let (status, cancelled, timed_out) = match ended {
Ended::Exited(status) => (Some(status), false, false),
Ended::Cancelled | Ended::TimedOut => {
let reaped = match child.terminate_tree_bounded(KILL_REAP_WAIT)? {
ReapOutcome::Exited(status) => Some(status),
ReapOutcome::TimedOut => None,
};
(
reaped,
matches!(ended, Ended::Cancelled),
matches!(ended, Ended::TimedOut),
)
}
};
let drain_end = Instant::now() + DRAIN_WAIT;
while !streams_done {
let left = drain_end.saturating_duration_since(Instant::now());
if left.is_zero() {
break;
}
match receiver.recv_timeout(left) {
Ok(message) => sinks.handle(message),
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => streams_done = true,
}
}
if let Some(capture) = stdout.as_mut() {
let _ = capture.cancel();
}
if let Some(capture) = stderr.as_mut() {
let _ = capture.cancel();
}
for message in receiver.try_iter() {
sinks.handle(message);
}
sinks.finish_stream();
let writer_result = writer.map(StdinWriter::finish).transpose();
let stdout_failure = super::capture_failure("stdout", stdout.as_ref());
let stderr_failure = super::capture_failure("stderr", stderr.as_ref());
stdout_failure?;
stderr_failure?;
writer_result?;
let Some(status) = status else {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"the terminated process was not reaped within its bound",
));
};
Ok(StreamOutput {
status,
cancelled,
timed_out,
stdout_tail: sinks.stdout_tail.into_bytes(),
stderr_tail: sinks.stderr_tail.into_text(),
})
}
#[derive(Clone, Copy)]
enum Ended {
Exited(ExitStatus),
Cancelled,
TimedOut,
}
enum Message {
Stdout(Vec<u8>),
Stderr(Vec<u8>),
}
fn forward(
sender: SyncSender<Message>,
wrap: fn(Vec<u8>) -> Message,
) -> crate::native_capture::ChunkSink {
Box::new(move |chunk: &[u8], stop: &AtomicBool| {
let mut message = wrap(chunk.to_vec());
loop {
match sender.try_send(message) {
Ok(()) | Err(TrySendError::Disconnected(_)) => return,
Err(TrySendError::Full(back)) => {
if stop.load(Ordering::Acquire) {
return;
}
message = back;
std::thread::sleep(Duration::from_millis(2));
}
}
}
})
}
struct Sinks<'a> {
on_stdout: &'a mut dyn FnMut(&[u8]),
on_stderr_line: &'a mut dyn FnMut(&str),
stdout_tail: Tail,
stderr_tail: Tail,
splitter: LineSplitter,
}
impl Sinks<'_> {
fn handle(&mut self, message: Message) {
match message {
Message::Stdout(chunk) => {
self.stdout_tail.push(&chunk);
(self.on_stdout)(&chunk);
}
Message::Stderr(chunk) => {
let Self {
on_stderr_line,
stderr_tail,
splitter,
..
} = self;
splitter.feed(&chunk, &mut |line| {
stderr_tail.push_line(line);
(**on_stderr_line)(line);
});
}
}
}
fn finish_stream(&mut self) {
let Self {
on_stderr_line,
stderr_tail,
splitter,
..
} = self;
splitter.finish(&mut |line| {
stderr_tail.push_line(line);
(**on_stderr_line)(line);
});
}
}
struct Tail {
cap: usize,
bytes: Vec<u8>,
}
impl Tail {
fn new(cap: usize) -> Self {
Self {
cap,
bytes: Vec::new(),
}
}
fn push(&mut self, chunk: &[u8]) {
if self.cap == 0 {
return;
}
if chunk.len() >= self.cap {
self.bytes.clear();
self.bytes
.extend_from_slice(&chunk[chunk.len() - self.cap..]);
return;
}
self.bytes.extend_from_slice(chunk);
if self.bytes.len() > self.cap.saturating_mul(2) {
self.trim();
}
}
fn push_line(&mut self, line: &str) {
self.push(line.as_bytes());
self.push(b"\n");
}
fn trim(&mut self) {
if self.bytes.len() > self.cap {
let cut = self.bytes.len() - self.cap;
self.bytes.drain(..cut);
}
}
fn into_bytes(mut self) -> Vec<u8> {
self.trim();
self.bytes
}
fn into_text(self) -> String {
let bytes = self.into_bytes();
let start = bytes
.iter()
.position(|&byte| byte & 0xC0 != 0x80)
.unwrap_or(bytes.len());
let mut text = String::from_utf8_lossy(&bytes[start..]).into_owned();
if text.ends_with('\n') {
text.pop();
}
text
}
}
#[derive(Default)]
struct LineSplitter {
line: Vec<u8>,
skip_lf: bool,
dropping: bool,
}
impl LineSplitter {
fn feed(&mut self, bytes: &[u8], emit: &mut dyn FnMut(&str)) {
for &byte in bytes {
if std::mem::take(&mut self.skip_lf) && byte == b'\n' {
continue;
}
match byte {
b'\n' | b'\r' => {
self.skip_lf = byte == b'\r';
self.end_line(emit);
}
_ if self.dropping => {}
_ => {
self.line.push(byte);
if self.line.len() > LINE_CAP {
self.line.clear();
self.dropping = true;
}
}
}
}
}
fn finish(&mut self, emit: &mut dyn FnMut(&str)) {
if !self.line.is_empty() || self.dropping {
self.end_line(emit);
}
}
fn end_line(&mut self, emit: &mut dyn FnMut(&str)) {
if self.dropping {
self.dropping = false;
emit(&format!("[stderr line over {LINE_CAP} bytes dropped]"));
} else {
let text = String::from_utf8_lossy(&self.line).into_owned();
emit(&text);
}
self.line.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lines(chunks: &[&[u8]]) -> Vec<String> {
let mut splitter = LineSplitter::default();
let mut out = Vec::new();
for chunk in chunks {
splitter.feed(chunk, &mut |line| out.push(line.to_owned()));
}
splitter.finish(&mut |line| out.push(line.to_owned()));
out
}
#[test]
fn lines_end_at_lf_cr_and_crlf_across_chunks() {
assert_eq!(lines(&[b"a\rb\nc\r\nd"]), ["a", "b", "c", "d"]);
assert_eq!(lines(&[b"x\r", b"\ny\n\nz"]), ["x", "y", "", "z"]);
assert_eq!(lines(&[b"par", b"tial\n"]), ["partial"]);
assert!(lines(&[b""]).is_empty());
}
#[test]
fn an_overlong_line_becomes_one_marker() {
let long = vec![b'x'; LINE_CAP + 10];
let got = lines(&[&long, b"\nnext\n"]);
let marker = format!("[stderr line over {LINE_CAP} bytes dropped]");
assert_eq!(got, [marker.as_str(), "next"]);
}
#[test]
fn a_tail_keeps_the_last_bytes() {
let mut tail = Tail::new(5);
tail.push(b"ab");
tail.push(b"cdefgh");
tail.push(b"i");
assert_eq!(tail.into_bytes(), b"efghi");
let mut none = Tail::new(0);
none.push(b"abc");
assert!(none.into_bytes().is_empty());
let mut many = Tail::new(4);
for byte in 0..100u8 {
many.push(&[byte]);
assert!(many.bytes.len() <= 9);
}
assert_eq!(many.into_bytes(), [96, 97, 98, 99]);
}
#[test]
fn the_stderr_tail_is_joined_lines_without_a_split_character() {
let mut tail = Tail::new(6);
for line in ["l1", "l2", "l3", "l4"] {
tail.push_line(line);
}
assert_eq!(tail.into_text(), "l3\nl4");
let mut tail = Tail::new(4);
tail.push_line("a\u{e9}\u{e9}");
assert_eq!(tail.into_text(), "\u{e9}");
}
#[test]
fn options_default_and_build() {
let options = StreamOptions::new();
assert_eq!(options.timeout, None);
assert_eq!(options.poll, Duration::from_millis(100));
assert_eq!(options.stdout_tail_cap, 64 * 1024);
assert_eq!(options.stderr_tail_cap, 64 * 1024);
assert!(options.no_window);
assert!(options.input.is_none());
let options = options
.timeout(Duration::from_secs(3))
.poll(Duration::from_millis(10))
.stdout_tail_cap(1)
.stderr_tail_cap(2)
.no_window(false)
.input(b"in".to_vec());
assert_eq!(options.timeout, Some(Duration::from_secs(3)));
assert_eq!(options.poll, Duration::from_millis(10));
assert_eq!((options.stdout_tail_cap, options.stderr_tail_cap), (1, 2));
assert!(!options.no_window);
assert_eq!(options.input.as_deref(), Some(&b"in"[..]));
}
}