use std::io::{self, Read, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::sync::{Arc, Condvar, Mutex};
use std::thread;
pub(crate) enum Input {
Command(Command),
Text(String),
}
pub(crate) struct Stream {
shared: Arc<Shared>,
}
pub(crate) struct Snapshot {
pub text: String,
pub truncated: bool,
pub rendered: Option<String>,
pub done: bool,
pub refused: Option<String>,
}
struct Shared {
state: Mutex<State>,
changed: Condvar,
}
#[derive(Default)]
struct State {
input: Vec<u8>,
truncated: bool,
input_done: bool,
refused: Option<String>,
rendered: Option<Vec<u8>>,
rendered_rows: usize,
rendered_done: bool,
abandoned: bool,
}
impl State {
fn done(&self) -> bool {
self.input_done && self.rendered_done
}
}
impl Shared {
fn update(&self, change: impl FnOnce(&mut State)) {
change(&mut self.state.lock().unwrap());
self.changed.notify_all();
}
}
impl Stream {
pub(crate) fn start(input: Input, renderer: Option<Command>, limit: u64) -> io::Result<Self> {
let shared = Arc::new(Shared {
state: Mutex::new(State::default()),
changed: Condvar::new(),
});
let (source, git): (Box<dyn Read + Send>, Option<Child>) = match input {
Input::Text(text) => (Box::new(io::Cursor::new(text.into_bytes())), None),
Input::Command(mut command) => {
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let stdout = child.stdout.take().expect("stdout was piped");
(Box::new(stdout), Some(child))
}
};
let mut renderer = renderer.and_then(|mut command| {
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.inspect_err(|e| log::warn!("renderer failed to spawn: {e}"))
.ok()
});
let sink = renderer.as_mut().and_then(|child| child.stdin.take());
match renderer {
Some(mut child) => {
let stdout = child.stdout.take().expect("stdout was piped");
shared.update(|state| state.rendered = Some(Vec::new()));
let shared = Arc::clone(&shared);
thread::spawn(move || draw(&shared, stdout, child));
}
None => shared.update(|state| state.rendered_done = true),
}
let reader = Arc::clone(&shared);
thread::spawn(move || read(&reader, source, sink, git, limit));
Ok(Self { shared })
}
pub(crate) fn wait_for(&self, rows: usize) {
let mut state = self.shared.state.lock().unwrap();
while !state.done() && state.rendered_rows < rows {
state = self.shared.changed.wait(state).unwrap();
}
}
pub(crate) fn is_done(&self) -> bool {
self.shared.state.lock().unwrap().done()
}
pub(crate) fn snapshot(&self) -> Snapshot {
let state = self.shared.state.lock().unwrap();
let done = state.done();
let text = String::from_utf8_lossy(&state.input);
let text = if !state.input_done || state.truncated {
crate::capped_input::whole(&text).to_owned()
} else {
text.into_owned()
};
let rendered = state.rendered.as_ref().map(|rendered| {
let rows = match rendered.iter().rposition(|&byte| byte == b'\n') {
_ if done => rendered.as_slice(),
Some(end) => &rendered[..=end],
None => &[],
};
String::from_utf8_lossy(rows).into_owned()
});
Snapshot {
text,
truncated: state.truncated,
rendered,
done,
refused: state.refused.clone(),
}
}
}
impl Drop for Stream {
fn drop(&mut self) {
self.shared.update(|state| state.abandoned = true);
}
}
fn read(
shared: &Shared,
mut source: Box<dyn Read + Send>,
mut sink: Option<ChildStdin>,
git: Option<Child>,
limit: u64,
) {
let mut git = git;
let complaint = git
.as_mut()
.and_then(|child| child.stderr.take())
.map(|mut pipe| {
thread::spawn(move || {
let mut said = String::new();
let _ = pipe.read_to_string(&mut said);
said
})
});
let mut buffer = vec![0; 64 * 1024];
let mut total = 0;
let mut stopped = false;
loop {
let read = match source.read(&mut buffer) {
Ok(0) | Err(_) => break,
Ok(read) => read,
};
let keep = read.min((limit + 1 - total) as usize);
total += keep as u64;
let chunk = &buffer[..keep];
if let Some(stdin) = &mut sink
&& stdin.write_all(chunk).is_err()
{
sink = None;
}
let mut abandoned = false;
shared.update(|state| {
state.input.extend_from_slice(chunk);
abandoned = state.abandoned;
});
if total > limit || abandoned {
stopped = total > limit;
break;
}
}
drop(sink);
let mut refused = None;
if let Some(mut child) = git {
if stopped {
let _ = child.kill();
}
let succeeded = child.wait().is_ok_and(|status| status.success());
let said = complaint.and_then(|thread| thread.join().ok());
if !succeeded && total == 0 {
refused = said.map(|said| said.lines().next().unwrap_or_default().to_owned());
}
}
shared.update(|state| {
state.truncated = stopped;
state.refused = refused;
state.input_done = true;
});
}
fn draw(shared: &Shared, mut stdout: ChildStdout, mut child: Child) {
let mut buffer = vec![0; 64 * 1024];
loop {
let read = match stdout.read(&mut buffer) {
Ok(0) | Err(_) => break,
Ok(read) => read,
};
let chunk = &buffer[..read];
shared.update(|state| {
if let Some(rendered) = &mut state.rendered {
rendered.extend_from_slice(chunk);
}
state.rendered_rows += chunk.iter().filter(|&&byte| byte == b'\n').count();
});
}
let succeeded = child.wait().is_ok_and(|status| status.success());
if !succeeded {
log::warn!("renderer exited unsuccessfully");
}
shared.update(|state| {
if !succeeded {
state.rendered = None;
}
state.rendered_done = true;
});
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
const PATCH: &str = "diff --git a/f.rs b/f.rs\n\
--- a/f.rs\n\
+++ b/f.rs\n\
@@ -1 +1 @@\n\
-one\n\
+two\n";
fn command(words: &[&str]) -> Command {
let mut command = Command::new(words[0]);
command.args(&words[1..]);
command
}
fn finished(input: Input, renderer: Option<Command>, limit: u64) -> Snapshot {
let stream = Stream::start(input, renderer, limit).unwrap();
stream.wait_for(usize::MAX);
stream.snapshot()
}
#[test]
fn everything_arrives_once_done() {
let snapshot = finished(
Input::Text("a\nb\n".into()),
Some(command(&["sed", "s/^/drawn /"])),
1_000,
);
assert!(snapshot.done);
assert_eq!(snapshot.text, "a\nb\n");
assert_eq!(snapshot.rendered.as_deref(), Some("drawn a\ndrawn b\n"));
assert!(!snapshot.truncated);
}
#[test]
fn the_first_rows_arrive_before_the_renderer_finishes() {
let renderer = command(&["awk", r#"{print} NR==2{fflush(); system("sleep 3")}"#]);
let started = Instant::now();
let stream =
Stream::start(Input::Text("a\nb\nc\nd\n".into()), Some(renderer), 1_000).unwrap();
stream.wait_for(2);
let snapshot = stream.snapshot();
assert!(started.elapsed() < Duration::from_secs(2));
assert!(!snapshot.done);
assert_eq!(snapshot.rendered.as_deref(), Some("a\nb\n"));
}
#[test]
fn text_still_being_written_is_cut_back_to_whole_hunks() {
let writer = command(&[
"sh",
"-c",
r#"printf '%s@@ -9 +9 @@\n-thr' "$0"; sleep 3; printf 'ee\n+four\n'"#,
PATCH,
]);
let stream = Stream::start(Input::Command(writer), Some(command(&["cat"])), 1_000).unwrap();
stream.wait_for(PATCH.lines().count() + 1);
let snapshot = stream.snapshot();
assert!(!snapshot.done);
assert_eq!(snapshot.text, PATCH);
}
#[test]
fn reading_stops_at_the_limit() {
let text = format!("{PATCH}@@ -9 +9 @@\n-three\n+four\n");
let snapshot = finished(
Input::Text(text.clone()),
Some(command(&["cat"])),
(text.len() - 1) as u64,
);
assert!(snapshot.done);
assert!(snapshot.truncated);
assert_eq!(snapshot.text, PATCH);
}
#[test]
fn without_a_renderer_the_text_is_all_there_is() {
let snapshot = finished(Input::Text("a\n".into()), None, 1_000);
assert!(snapshot.done);
assert_eq!(snapshot.text, "a\n");
assert_eq!(snapshot.rendered, None);
}
#[test]
fn a_renderer_that_fails_has_drawn_nothing() {
let snapshot = finished(
Input::Text("a\n".into()),
Some(command(&["sh", "-c", "cat >/dev/null; exit 1"])),
1_000,
);
assert!(snapshot.done);
assert_eq!(snapshot.rendered, None);
}
#[test]
fn a_command_git_refuses_says_why() {
let snapshot = finished(
Input::Command(command(&["sh", "-c", "echo 'fatal: no' >&2; exit 128"])),
Some(command(&["cat"])),
1_000,
);
assert!(snapshot.done);
assert_eq!(snapshot.refused.as_deref(), Some("fatal: no"));
}
}