blob: 6ba1418803ebf997a93365880ca20e30b61e9707 [file] [log] [blame]
//! Build progress tracking and reporting, for the purpose of display to the
//! user.
use crate::{
graph::Build, graph::BuildId, process::Termination, task::TaskResult, terminal,
work::BuildState, work::StateCounts,
};
use std::collections::VecDeque;
use std::io::Write;
use std::sync::Arc;
use std::sync::Condvar;
use std::sync::Mutex;
use std::time::Duration;
use std::time::Instant;
/// Compute the message to display on the console for a given build.
pub fn build_message(build: &Build) -> &str {
build
.desc
.as_ref()
.filter(|desc| !desc.is_empty())
.unwrap_or_else(|| build.cmdline.as_ref().unwrap())
}
/// Trait for build progress notifications.
pub trait Progress {
/// Called as individual build tasks progress through build states.
fn update(&mut self, counts: &StateCounts);
/// Called when a task starts.
fn task_started(&mut self, id: BuildId, build: &Build);
/// Called when a task's last line of output changes.
fn task_output(&mut self, id: BuildId, line: Vec<u8>);
/// Called when a task completes.
fn task_finished(&mut self, id: BuildId, build: &Build, result: &TaskResult);
/// Log a line of output without corrupting the progress display.
/// This line is persisted beyond further progress updates. For example,
/// used when a task fails; we want the final output to show that failed
/// task's output even if we do more work after it fails.
fn log(&mut self, msg: &str);
}
/// Currently running build task, as tracked for progress updates.
struct Task {
id: BuildId,
/// When the task started running.
start: Instant,
/// Build status message for the task.
message: String,
/// Last line of output from the task.
last_line: Option<String>,
}
/// Progress implementation for "dumb" console, without any overprinting.
#[derive(Default)]
pub struct DumbConsoleProgress {
/// Whether to print command lines of started programs.
verbose: bool,
/// The id of the last command printed, used to avoid printing it twice
/// when we have two updates from the same command in a row.
last_started: Option<BuildId>,
}
impl DumbConsoleProgress {
pub fn new(verbose: bool) -> Self {
Self {
verbose,
last_started: None,
}
}
}
impl Progress for DumbConsoleProgress {
fn update(&mut self, _counts: &StateCounts) {
// ignore
}
fn task_started(&mut self, id: BuildId, build: &Build) {
self.log(if self.verbose {
build.cmdline.as_ref().unwrap()
} else {
build_message(build)
});
self.last_started = Some(id);
}
fn task_output(&mut self, _id: BuildId, _line: Vec<u8>) {
// ignore
}
fn task_finished(&mut self, id: BuildId, build: &Build, result: &TaskResult) {
match result.termination {
Termination::Success => {
if result.output.is_empty() || self.last_started == Some(id) {
// Output is empty, or we just printed the command, don't print it again.
} else {
self.log(build_message(build))
}
}
Termination::Interrupted => self.log(&format!("interrupted: {}", build_message(build))),
Termination::Failure => self.log(&format!("failed: {}", build_message(build))),
};
if !result.output.is_empty() {
std::io::stdout().write_all(&result.output).unwrap();
}
}
fn log(&mut self, msg: &str) {
println!("{}", msg);
}
}
/// Progress implementation for "fancy" console, with progress bar etc.
/// Each time it prints, it clears from the cursor to the end of the console,
/// prints the status text, and then moves moves the cursor back up to the
/// start position. This means on errors etc. we can clear any status by
/// clearing the console too.
pub struct FancyConsoleProgress {
state: Arc<Mutex<FancyState>>,
}
/// Screen updates happen after this duration passes, to reduce the amount
/// of printing in the case of rapid updates. This helps with terminal flicker.
const UPDATE_DELAY: Duration = std::time::Duration::from_millis(50);
impl FancyConsoleProgress {
pub fn new(verbose: bool) -> Self {
let dirty_cond = Arc::new(Condvar::new());
let state = Arc::new(Mutex::new(FancyState {
done: false,
dirty: false,
dirty_cond: dirty_cond.clone(),
counts: StateCounts::default(),
tasks: VecDeque::new(),
verbose,
}));
// Thread to debounce status updates -- waits a bit, then prints after
// any dirty state.
std::thread::spawn({
let state = state.clone();
move || loop {
// Wait to be notified of a display update, or timeout at 500ms.
// The timeout is for the case where there are lengthy build
// steps and the progress will show how long they've been
// running.
{
let (state, _) = dirty_cond
.wait_timeout_while(
state.lock().unwrap(),
Duration::from_millis(500),
|state| !state.dirty,
)
.unwrap();
if state.done {
break;
}
}
// Delay a little bit in case more display updates come in.
std::thread::sleep(UPDATE_DELAY);
// Update regardless of whether we timed out or not.
state.lock().unwrap().print_progress();
}
});
FancyConsoleProgress { state }
}
}
impl Progress for FancyConsoleProgress {
fn update(&mut self, counts: &StateCounts) {
self.state.lock().unwrap().update(counts);
}
fn task_started(&mut self, id: BuildId, build: &Build) {
self.state.lock().unwrap().task_started(id, build);
}
fn task_output(&mut self, id: BuildId, line: Vec<u8>) {
self.state.lock().unwrap().task_output(id, line);
}
fn task_finished(&mut self, id: BuildId, build: &Build, result: &TaskResult) {
self.state.lock().unwrap().task_finished(id, build, result);
}
fn log(&mut self, msg: &str) {
self.state.lock().unwrap().log(msg);
}
}
impl Drop for FancyConsoleProgress {
fn drop(&mut self) {
self.state.lock().unwrap().cleanup();
}
}
struct FancyState {
done: bool,
dirty: bool,
dirty_cond: Arc<Condvar>,
/// Counts of tasks in each state. TODO: pass this as function args?
counts: StateCounts,
/// Build tasks that are currently executing.
/// Pushed to as tasks are started, so it's always in order of age.
tasks: VecDeque<Task>,
/// Whether to print command lines of started programs.
verbose: bool,
}
impl FancyState {
fn dirty(&mut self) {
self.dirty = true;
self.dirty_cond.notify_one();
}
fn update(&mut self, counts: &StateCounts) {
self.counts = counts.clone();
self.dirty();
}
fn task_started(&mut self, id: BuildId, build: &Build) {
if self.verbose {
self.log(build.cmdline.as_ref().unwrap());
}
let message = build_message(build);
self.tasks.push_back(Task {
id,
start: Instant::now(),
message: message.to_string(),
last_line: None,
});
self.dirty();
}
fn task_output(&mut self, id: BuildId, line: Vec<u8>) {
let task = self.tasks.iter_mut().find(|t| t.id == id).unwrap();
task.last_line = Some(String::from_utf8_lossy(&line).into_owned());
self.dirty();
}
fn task_finished(&mut self, id: BuildId, build: &Build, result: &TaskResult) {
self.tasks
.remove(self.tasks.iter().position(|t| t.id == id).unwrap());
match result.termination {
Termination::Success => {
if result.output.is_empty() {
// Common case: don't show anything.
} else {
self.log(build_message(build))
}
}
Termination::Interrupted => self.log(&format!("interrupted: {}", build_message(build))),
Termination::Failure => self.log(&format!("failed: {}", build_message(build))),
};
if !result.output.is_empty() {
std::io::stdout().write_all(&result.output).unwrap();
}
self.dirty();
}
fn log(&mut self, msg: &str) {
self.clear_progress();
println!("{}", msg);
self.dirty();
}
fn cleanup(&mut self) {
self.clear_progress();
self.done = true;
self.dirty(); // let thread quit
}
fn clear_progress(&self) {
// If the user hit ctl-c, it may have printed something on the line.
// So \r to go to first column first, then clear anything below.
std::io::stdout().write_all(b"\r\x1b[J").unwrap();
}
fn print_progress(&mut self) {
self.clear_progress();
let failed = self.counts.get(BuildState::Failed);
let mut progress_line = format!(
"[{}] {}/{} done, ",
progress_bar(&self.counts, 40),
self.counts.get(BuildState::Done) + failed,
self.counts.total()
);
if failed > 0 {
progress_line.push_str(&format!("{} failed, ", failed));
}
progress_line.push_str(&format!(
"{}/{} running",
self.tasks.len(),
self.counts.get(BuildState::Queued)
+ self.counts.get(BuildState::Running)
+ self.counts.get(BuildState::Ready),
));
println!("{}", progress_line);
let mut lines = 1;
let max_cols = terminal::get_cols().unwrap_or(80);
let max_tasks = 8;
let now = Instant::now();
for task in self.tasks.iter().take(max_tasks) {
let delta = now.duration_since(task.start).as_secs() as usize;
println!("{}", task_message(&task.message, delta, max_cols));
lines += 1;
if let Some(line) = &task.last_line {
let max_len = max_cols - 2;
println!(" {}", truncate(line, max_len));
lines += 1;
}
}
if self.tasks.len() > max_tasks {
let remaining = self.tasks.len() - max_tasks;
println!("...and {} more", remaining);
lines += 1;
}
// Move cursor up to the first printed line, for overprinting.
print!("\x1b[{}A", lines);
self.dirty = false;
}
}
/// Format a task's status message to optionally include how long it has been running
/// and also to fit within a maximum number of terminal columns.
fn task_message(message: &str, seconds: usize, max_cols: usize) -> String {
let time_note = if seconds > 2 {
format!(" ({}s)", seconds)
} else {
"".into()
};
let mut out = message.to_owned();
if out.len() + time_note.len() >= max_cols {
out.truncate(max_cols - time_note.len() - 3);
out.push_str("...");
}
out.push_str(&time_note);
out
}
fn truncate(s: &str, mut max: usize) -> &str {
if max >= s.len() {
return s;
}
while !s.is_char_boundary(max) {
max -= 1;
}
&s[..max]
}
/// Render a StateCounts as an ASCII progress bar.
fn progress_bar(counts: &StateCounts, bar_size: usize) -> String {
let mut bar = String::with_capacity(bar_size);
let mut sum: usize = 0;
let total = counts.total();
if total == 0 {
return " ".repeat(bar_size);
}
for (count, ch) in [
(
counts.get(BuildState::Done) + counts.get(BuildState::Failed),
'=',
),
(
counts.get(BuildState::Queued)
+ counts.get(BuildState::Running)
+ counts.get(BuildState::Ready),
'-',
),
(counts.get(BuildState::Want), ' '),
] {
sum += count;
let mut target_size = sum * bar_size / total;
if count > 0 && target_size == bar.len() && target_size < bar_size {
// Special case: for non-zero count, ensure we always get at least
// one tick.
target_size += 1;
}
while bar.len() < target_size {
bar.push(ch);
}
}
bar
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn progress_bar_rendering() {
let mut counts = StateCounts::default();
// Don't crash if we show progress before having any tasks.
assert_eq!(progress_bar(&counts, 10), " ");
counts.add(BuildState::Want, 100);
assert_eq!(progress_bar(&counts, 10), " ");
// Half want -> ready.
counts.add(BuildState::Want, -50);
counts.add(BuildState::Ready, 50);
assert_eq!(progress_bar(&counts, 10), "----- ");
// One ready -> done.
counts.add(BuildState::Ready, -1);
counts.add(BuildState::Done, 1);
assert_eq!(progress_bar(&counts, 10), "=---- ");
// All but one want -> ready.
counts.add(BuildState::Want, -49);
counts.add(BuildState::Ready, 49);
assert_eq!(progress_bar(&counts, 10), "=-------- ");
// All want -> ready.
counts.add(BuildState::Want, -1);
counts.add(BuildState::Ready, 1);
assert_eq!(progress_bar(&counts, 10), "=---------");
}
#[test]
fn task_rendering() {
assert_eq!(task_message("building foo.o", 0, 80), "building foo.o");
assert_eq!(task_message("building foo.o", 0, 10), "buildin...");
assert_eq!(task_message("building foo.o", 0, 5), "bu...");
}
#[test]
fn task_rendering_with_time() {
assert_eq!(task_message("building foo.o", 5, 80), "building foo.o (5s)");
assert_eq!(task_message("building foo.o", 5, 10), "bu... (5s)");
}
#[test]
fn truncate_utf8() {
let text = "utf8 progress bar: ━━━━━━━━━━━━";
for len in 10..text.len() {
// test passes if this doesn't panic
truncate(text, len);
}
}
}