use std::io::Read;
pub(crate) const OUTPUT_DRAIN_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
const OUTPUT_CHUNK_BYTES: usize = 16 * 1024;
pub(crate) struct StreamDrain {
finished: std::sync::mpsc::Receiver<std::io::Result<()>>,
}
pub(crate) fn spawn_drain<F>(read_to_end_of_stream: F) -> StreamDrain
where
F: FnOnce() -> std::io::Result<()> + Send + 'static,
{
let (sender, finished) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = sender.send(read_to_end_of_stream());
});
StreamDrain { finished }
}
pub(crate) fn wait_for_drains<'a>(
drains: impl IntoIterator<Item = &'a StreamDrain>,
budget: std::time::Duration,
) -> std::io::Result<bool> {
let deadline = std::time::Instant::now() + budget;
let mut complete = true;
for drain in drains {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
match drain.finished.recv_timeout(remaining) {
Ok(Ok(())) => {}
Ok(Err(error)) => return Err(error),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => complete = false,
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
return Err(std::io::Error::other("output reader thread panicked"));
}
}
}
Ok(complete)
}
pub(crate) struct OutputReader {
buffer: std::sync::Arc<std::sync::Mutex<Vec<u8>>>,
drain: StreamDrain,
}
pub(crate) struct DrainedOutput {
pub(crate) stdout: String,
pub(crate) stderr: String,
pub(crate) complete: bool,
}
pub(crate) fn output_reader(stream: impl Read + Send + 'static) -> OutputReader {
spawn_capturing_drain(stream, |_chunk| {})
}
pub(crate) fn output_reader_tee(stream: impl Read + Send + 'static) -> OutputReader {
use std::io::Write as _;
spawn_capturing_drain(stream, |chunk| {
let _ = std::io::stderr().write_all(chunk);
})
}
fn spawn_capturing_drain(
mut stream: impl Read + Send + 'static,
mut on_chunk: impl FnMut(&[u8]) + Send + 'static,
) -> OutputReader {
let buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = std::sync::Arc::clone(&buffer);
let drain = spawn_drain(move || {
let mut chunk = [0_u8; OUTPUT_CHUNK_BYTES];
loop {
match stream.read(&mut chunk) {
Ok(0) => break Ok(()),
Ok(count) => {
on_chunk(&chunk[..count]);
lock(&sink).extend_from_slice(&chunk[..count]);
}
Err(error) => break Err(error),
}
}
});
OutputReader { buffer, drain }
}
fn lock(buffer: &std::sync::Mutex<Vec<u8>>) -> std::sync::MutexGuard<'_, Vec<u8>> {
buffer.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub(crate) fn collect_output_within(
stdout: Option<OutputReader>,
stderr: Option<OutputReader>,
budget: std::time::Duration,
) -> std::io::Result<DrainedOutput> {
let readers = [stdout, stderr];
let complete = wait_for_drains(readers.iter().flatten().map(|reader| &reader.drain), budget)?;
let mut streams = readers
.into_iter()
.map(|reader| reader.map_or_else(String::new, |reader| decode(&reader)));
Ok(DrainedOutput {
stdout: streams.next().unwrap_or_default(),
stderr: streams.next().unwrap_or_default(),
complete,
})
}
fn decode(reader: &OutputReader) -> String {
String::from_utf8_lossy(&lock(&reader.buffer)).into_owned()
}
#[cfg(test)]
mod tests {
use super::{
OUTPUT_DRAIN_GRACE, collect_output_within, output_reader, output_reader_tee, spawn_drain, wait_for_drains,
};
use std::time::{Duration, Instant};
#[test]
fn a_stream_that_reaches_end_of_stream_reports_the_drain_complete() {
let reader = output_reader(std::io::Cursor::new(b"finished".to_vec()));
let drained = collect_output_within(Some(reader), None, OUTPUT_DRAIN_GRACE).expect("a readable stream");
assert!(drained.complete);
assert_eq!(drained.stdout, "finished");
assert_eq!(drained.stderr, "");
}
#[test]
fn output_reader_tee_captures_the_same_bytes_as_output_reader() {
let reader = output_reader_tee(std::io::Cursor::new(b"tapped through".to_vec()));
let drained = collect_output_within(Some(reader), None, OUTPUT_DRAIN_GRACE).expect("a readable stream");
assert!(drained.complete);
assert_eq!(drained.stdout, "tapped through");
}
#[test]
fn a_stream_that_never_ends_gives_the_budget_back_and_reports_incomplete() {
let (holder, receiver) = std::sync::mpsc::channel::<()>();
let stalled = spawn_drain(move || {
let _ = receiver.recv();
Ok(())
});
let budget = Duration::from_millis(200);
let started = Instant::now();
let complete = wait_for_drains([&stalled], budget).expect("a stalled drain is not an error");
let elapsed = started.elapsed();
assert!(!complete, "a stream with a live writer must not report complete");
assert!(elapsed >= budget, "the drain returned before its budget elapsed");
assert!(
elapsed < budget * 10,
"the drain took {elapsed:?}, which is not bounded by its {budget:?} budget"
);
drop(holder);
}
#[test]
fn two_stalled_streams_share_a_single_budget() {
let mut holders = Vec::new();
let mut drains = Vec::new();
for _ in 0..2 {
let (holder, receiver) = std::sync::mpsc::channel::<()>();
holders.push(holder);
drains.push(spawn_drain(move || {
let _ = receiver.recv();
Ok(())
}));
}
let budget = Duration::from_millis(200);
let started = Instant::now();
let complete = wait_for_drains(drains.iter(), budget).expect("stalled drains are not an error");
let elapsed = started.elapsed();
assert!(!complete);
assert!(
elapsed < budget * 2,
"two stalled streams took {elapsed:?}, so the budget was charged per stream"
);
drop(holders);
}
}