use std::cell::RefCell;
use std::io::{IsTerminal, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, Weak};
use std::time::Instant;
const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const MAX_LINES: usize = 12;
struct Slot {
name: String,
started: Instant,
last_output: Instant,
next_beat: u64,
buf: Vec<u8>,
running: bool,
done: bool,
}
pub struct Stage {
slots: Mutex<Vec<Slot>>,
out: Mutex<usize>,
live: bool,
on_push: bool,
stop: AtomicBool,
}
thread_local! {
static SINK: RefCell<Option<(Arc<Stage>, usize)>> = const { RefCell::new(None) };
}
impl Stage {
pub fn begin(names: &[&str]) -> Arc<Stage> {
let now = Instant::now();
let stage = Arc::new(Stage {
slots: Mutex::new(
names
.iter()
.map(|n| Slot {
name: crate::ui::sanitize(
n.strip_prefix("pre-commit-")
.or_else(|| n.strip_prefix("pre-push-"))
.unwrap_or(n),
),
started: now,
last_output: now,
next_beat: HEARTBEAT_SECS,
buf: Vec::new(),
running: false,
done: false,
})
.collect(),
),
out: Mutex::new(0),
live: enabled() && watching(),
on_push: names.iter().any(|n| n.starts_with("pre-push-")),
stop: AtomicBool::new(false),
});
if stage.live {
let weak = Arc::downgrade(&stage);
let _ = std::thread::Builder::new()
.name("amont-live".into())
.spawn(move || tick(weak));
} else if enabled() {
let weak = Arc::downgrade(&stage);
let _ = std::thread::Builder::new()
.name("amont-heartbeat".into())
.spawn(move || heartbeat(weak));
}
stage
}
pub fn enter(self: &Arc<Stage>, idx: usize) -> SinkGuard {
{
let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
if let Some(slot) = slots.get_mut(idx) {
slot.running = true;
slot.started = Instant::now();
slot.last_output = slot.started;
slot.next_beat = HEARTBEAT_SECS;
}
}
SINK.with(|s| *s.borrow_mut() = Some((Arc::clone(self), idx)));
SinkGuard
}
pub fn append_raw(&self, idx: usize, bytes: &[u8]) {
let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
if let Some(slot) = slots.get_mut(idx) {
if !slot.done {
slot.buf.extend_from_slice(bytes);
slot.last_output = Instant::now();
}
}
}
fn append_line(&self, idx: usize, line: &str) {
let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
if let Some(slot) = slots.get_mut(idx) {
if !slot.done {
slot.buf.extend_from_slice(line.as_bytes());
slot.buf.push(b'\n');
slot.last_output = Instant::now();
}
}
}
pub fn finish(&self, idx: usize) {
let block = {
let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
let Some(slot) = slots.get_mut(idx) else {
return;
};
slot.done = true;
slot.running = false;
std::mem::take(&mut slot.buf)
};
if block.is_empty() && !self.live {
return;
}
let mut drawn = self.out.lock().unwrap_or_else(|p| p.into_inner());
if !block.is_empty() {
if *drawn > 0 {
let mut err = std::io::stderr().lock();
let _ = write!(err, "\x1b[{}A\x1b[J", *drawn);
let _ = err.flush();
*drawn = 0;
}
let stdout = std::io::stdout();
let mut handle = stdout.lock();
let _ = handle.write_all(&block);
let _ = handle.flush();
}
self.repaint(&mut drawn);
}
fn repaint(&self, drawn: &mut usize) {
if !self.live {
return;
}
let entries: Vec<Row> = {
let slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
let now = Instant::now();
slots
.iter()
.filter(|s| s.running && !s.done)
.map(|s| Row {
name: s.name.clone(),
elapsed: now.duration_since(s.started).as_secs_f64(),
quiet: now.duration_since(s.last_output).as_secs_f64(),
})
.collect()
};
let text = region(&entries, term_width(), budgets());
let mut paint = String::new();
if *drawn > 0 {
paint.push_str(&format!("\x1b[{}A\x1b[J", *drawn));
}
paint.push_str(&text);
if paint.is_empty() {
return;
}
let mut err = std::io::stderr().lock();
let _ = err.write_all(paint.as_bytes());
let _ = err.flush();
*drawn = text.matches('\n').count();
}
}
impl Drop for Stage {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if !self.live {
return;
}
let drawn = self.out.get_mut().unwrap_or_else(|p| p.into_inner());
if *drawn > 0 {
let mut err = std::io::stderr().lock();
let _ = write!(err, "\x1b[{}A\x1b[J", *drawn);
let _ = err.flush();
*drawn = 0;
}
}
}
fn tick(weak: Weak<Stage>) {
loop {
std::thread::sleep(std::time::Duration::from_millis(80));
let Some(stage) = weak.upgrade() else { return };
if stage.stop.load(Ordering::Relaxed) {
return;
}
let mut drawn = stage.out.lock().unwrap_or_else(|p| p.into_inner());
stage.repaint(&mut drawn);
}
}
#[derive(Debug, Clone)]
pub struct Row {
pub name: String,
pub elapsed: f64,
pub quiet: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct Budgets {
pub idle: u64,
pub ceiling: u64,
}
fn budgets() -> Budgets {
Budgets {
idle: crate::hooks::common::idle_timeout(),
ceiling: crate::hooks::common::check_timeout(),
}
}
const QUIET_NOTE_SECS: f64 = 30.0;
const HEARTBEAT_SECS: u64 = 60;
fn elapsed_column(secs: f64) -> String {
if secs < 60.0 {
format!("{secs:>5.1}s")
} else {
format!("{:>6}", crate::hooks::common::human_secs(secs as u64))
}
}
fn region(entries: &[Row], width: usize, budgets: Budgets) -> String {
if entries.is_empty() {
return String::new();
}
let pad = entries
.iter()
.take(MAX_LINES)
.map(|r| r.name.chars().count())
.max()
.unwrap_or(0);
let mut out = String::new();
for row in entries.iter().take(MAX_LINES) {
let frame = FRAMES[((row.elapsed * 10.0) as usize) % FRAMES.len()];
let name = &row.name;
let mut line = format!("{frame} {name:<pad$} {}", elapsed_column(row.elapsed));
if row.quiet >= QUIET_NOTE_SECS {
let quiet = crate::hooks::common::human_secs(row.quiet as u64);
if budgets.idle > 0 {
line.push_str(&format!(
" · quiet {quiet}/{}",
crate::hooks::common::human_secs(budgets.idle)
));
} else {
line.push_str(&format!(" · quiet {quiet}"));
}
}
if budgets.ceiling > 0 && row.elapsed >= 0.8 * budgets.ceiling as f64 {
line.push_str(&format!(
" · {}/{}",
crate::hooks::common::human_secs(row.elapsed as u64),
crate::hooks::common::human_secs(budgets.ceiling)
));
}
if line.chars().count() > width {
out.extend(line.chars().take(width));
} else {
out.push_str(&line);
}
out.push('\n');
}
if entries.len() > MAX_LINES {
out.push_str(&format!("… and {} more\n", entries.len() - MAX_LINES));
}
out
}
fn heartbeat(weak: Weak<Stage>) {
loop {
std::thread::sleep(std::time::Duration::from_secs(1));
let Some(stage) = weak.upgrade() else { return };
if stage.stop.load(Ordering::Relaxed) {
return;
}
let due: Vec<(Row, bool)> = {
let mut slots = stage.slots.lock().unwrap_or_else(|p| p.into_inner());
let now = Instant::now();
let mut due = Vec::new();
for s in slots.iter_mut().filter(|s| s.running && !s.done) {
let elapsed = now.duration_since(s.started).as_secs();
if elapsed >= s.next_beat {
let first = s.next_beat == HEARTBEAT_SECS;
s.next_beat += HEARTBEAT_SECS;
due.push((
Row {
name: s.name.clone(),
elapsed: elapsed as f64,
quiet: now.duration_since(s.last_output).as_secs_f64(),
},
first,
));
}
}
due
};
if due.is_empty() {
continue;
}
let text: String = due
.iter()
.map(|(row, first)| beat_line(row, *first, budgets(), stage.on_push))
.collect();
let _guard = stage.out.lock().unwrap_or_else(|p| p.into_inner());
let mut err = std::io::stderr().lock();
let _ = err.write_all(text.as_bytes());
let _ = err.flush();
}
}
fn beat_line(row: &Row, first: bool, budgets: Budgets, on_push: bool) -> String {
use crate::hooks::common::human_secs;
let mut line = format!(
" … {} still running: {}, last output {} ago",
row.name,
human_secs(row.elapsed as u64),
human_secs(row.quiet as u64)
);
if first {
let idle = match budgets.idle {
0 => "off".to_string(),
s => human_secs(s),
};
let ceiling = match budgets.ceiling {
0 => "off".to_string(),
s => human_secs(s),
};
line.push_str(&format!(
" (killed after {idle} of silence or {ceiling} in total — amont.idleTimeout / amont.timeout)"
));
if on_push {
line.push_str(concat!(
"\n git opened its connection to the remote before calling this",
"\n gate, and it stays idle until the gate finishes. A remote may",
"\n close it first — GitHub does — and the push then fails with",
"\n \"Connection reset by peer\", naming the network rather than the",
"\n wait. ssh keepalive does not prevent this.",
"\n Declaring this check at pre-commit moves it off the push path —",
"\n see \"Moving a gate entry earlier\" in the docs.",
));
}
}
line.push('\n');
line
}
pub fn term_width() -> usize {
std::env::var("COLUMNS")
.ok()
.and_then(|c| c.parse::<usize>().ok())
.filter(|w| *w >= 20)
.unwrap_or(100)
}
pub struct FinishOnDrop<'a> {
stage: &'a Stage,
idx: usize,
}
impl<'a> FinishOnDrop<'a> {
pub fn new(stage: &'a Stage, idx: usize) -> FinishOnDrop<'a> {
FinishOnDrop { stage, idx }
}
}
impl Drop for FinishOnDrop<'_> {
fn drop(&mut self) {
self.stage.finish(self.idx);
}
}
pub struct SinkGuard;
impl Drop for SinkGuard {
fn drop(&mut self) {
SINK.with(|s| *s.borrow_mut() = None);
}
}
pub fn current_sink() -> Option<(Arc<Stage>, usize)> {
SINK.with(|s| s.borrow().clone())
}
pub fn say(line: &str) {
let routed = SINK.with(|s| {
s.borrow().as_ref().map(|(stage, idx)| {
stage.append_line(*idx, line);
})
});
if routed.is_none() {
println!("{line}");
}
}
#[macro_export]
macro_rules! say {
($($arg:tt)*) => {
$crate::live::say(&format!($($arg)*))
};
}
pub fn quiet() -> bool {
static QUIET: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*QUIET.get_or_init(|| {
decide(
crate::config::enumerated_or("amont.quiet", QUIET_VALUES, "never"),
watching(),
)
})
}
pub const QUIET_VALUES: &[&str] = &["never", "auto", "always"];
fn decide(setting: &str, watching: bool) -> bool {
match setting {
"always" => true,
"auto" => !watching,
_ => false,
}
}
pub fn enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| crate::config::boolean_or("amont.progress", true))
}
pub fn watching() -> bool {
static WATCHING: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*WATCHING.get_or_init(|| {
if !std::io::stderr().is_terminal() {
return false;
}
match std::env::var("TERM") {
Ok(term) => term != "dumb",
Err(_) => !cfg!(windows),
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quiet_asks_who_is_reading() {
assert!(!decide("never", true));
assert!(!decide("never", false));
assert!(always_and_auto_agree_at_a_terminal());
assert!(decide("auto", false), "captured: nobody is watching");
assert!(decide("always", true));
assert!(decide("always", false));
assert!(!decide("shhh", false));
}
fn always_and_auto_agree_at_a_terminal() -> bool {
!decide("auto", true) && decide("always", true)
}
#[test]
fn slots_do_not_share_a_buffer() {
let stage = Stage::begin(&["a", "b"]);
std::thread::scope(|scope| {
for idx in 0..2 {
let stage = Arc::clone(&stage);
scope.spawn(move || {
let _guard = stage.enter(idx);
for i in 0..50 {
say(&format!("check-{idx} line-{i}"));
std::thread::yield_now();
}
});
}
});
let slots = stage.slots.lock().unwrap();
for idx in 0..2 {
let text = String::from_utf8(slots[idx].buf.clone()).unwrap();
assert_eq!(text.lines().count(), 50);
assert!(
text.lines()
.all(|l| l.starts_with(&format!("check-{idx} "))),
"a foreign line landed in slot {idx}"
);
}
}
#[test]
fn no_sink_means_no_capture() {
let stage = Stage::begin(&["a"]);
say("goes to stdout, not to a slot");
let slots = stage.slots.lock().unwrap();
assert!(slots[0].buf.is_empty());
}
#[test]
fn a_finished_slot_takes_no_more_writes() {
let stage = Stage::begin(&["a"]);
stage.append_raw(0, b"before\n");
stage.finish(0);
stage.append_raw(0, b"after\n");
let slots = stage.slots.lock().unwrap();
assert!(slots[0].buf.is_empty(), "a write landed after finish");
}
#[test]
fn a_slot_name_is_sanitised_at_begin() {
let stage = Stage::begin(&["evil\u{1b}[2Jname\rhere"]);
let slots = stage.slots.lock().unwrap();
assert!(!slots[0].name.contains('\u{1b}'), "{:?}", slots[0].name);
assert!(!slots[0].name.contains('\r'), "{:?}", slots[0].name);
}
#[test]
fn a_slot_name_drops_the_stage_prefix() {
let stage = Stage::begin(&["pre-commit-clippy", "pre-push-run-tests", "bare"]);
let slots = stage.slots.lock().unwrap();
assert_eq!(slots[0].name, "clippy");
assert_eq!(slots[1].name, "run-tests");
assert_eq!(slots[2].name, "bare");
}
fn row(name: &str, elapsed: f64) -> Row {
Row {
name: name.into(),
elapsed,
quiet: 0.0,
}
}
const B: Budgets = Budgets {
idle: 120,
ceiling: 3600,
};
#[test]
fn frames_advance_with_time() {
let a = region(&[row("clippy", 0.0)], 80, B);
let b = region(&[row("clippy", 0.1)], 80, B);
let c = region(&[row("clippy", 1.0)], 80, B);
assert_ne!(a.chars().next(), b.chars().next());
assert_eq!(a.chars().next(), c.chars().next(), "10 frames per second");
}
#[test]
fn region_lines_align() {
let text = region(&[row("a", 0.0), row("longer-name", 0.0)], 80, B);
let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
assert_eq!(widths[0], widths[1], "{text:?}");
let text = region(&[row("a", 3.2), row("b", 492.0)], 80, B);
let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
assert_eq!(widths[0], widths[1], "{text:?}");
assert!(text.contains("8m12s"), "{text:?}");
}
#[test]
fn region_caps_and_counts_the_rest() {
let entries: Vec<Row> = (0..13).map(|i| row(&format!("check-{i}"), 0.0)).collect();
let text = region(&entries, 80, B);
assert_eq!(text.lines().count(), MAX_LINES + 1);
assert!(text.ends_with("… and 1 more\n"), "{text:?}");
}
#[test]
fn region_respects_width() {
let text = region(&[row("a-name-much-longer-than-the-terminal", 0.0)], 20, B);
assert!(text.lines().all(|l| l.chars().count() <= 20), "{text:?}");
}
#[test]
fn an_empty_region_is_empty() {
assert_eq!(region(&[], 80, B), "");
}
#[test]
fn a_quiet_check_shows_its_silence_against_the_budget() {
let mut r = row("cargo-test", 300.0);
r.quiet = 5.0;
assert!(!region(&[r.clone()], 80, B).contains("quiet"));
r.quiet = 45.0;
let text = region(&[r.clone()], 80, B);
assert!(text.contains("quiet 45s/2m00s"), "{text:?}");
let off = Budgets { idle: 0, ..B };
let text = region(&[r], 80, off);
assert!(
text.contains("quiet 45s") && !text.contains('/'),
"{text:?}"
);
}
#[test]
fn the_ceiling_shows_only_when_it_is_near() {
assert!(!region(&[row("cargo-test", 1000.0)], 80, B).contains("/1h00m"));
let text = region(&[row("cargo-test", 3000.0)], 80, B);
assert!(text.contains("50m00s/1h00m"), "{text:?}");
let off = Budgets { ceiling: 0, ..B };
assert!(!region(&[row("cargo-test", 3000.0)], 80, off).contains("/"));
}
#[test]
fn a_heartbeat_names_the_budgets_once() {
let mut r = row("cargo-test", 60.0);
r.quiet = 2.0;
let first = beat_line(&r, true, B, false);
assert!(
first.contains("cargo-test still running: 1m00s"),
"{first:?}"
);
assert!(first.contains("last output 2s ago"), "{first:?}");
assert!(
first.contains("2m00s of silence or 1h00m in total"),
"{first:?}"
);
assert!(first.contains("amont.idleTimeout"), "{first:?}");
let later = beat_line(&r, false, B, false);
assert!(!later.contains("amont.idleTimeout"), "{later:?}");
let off = beat_line(
&r,
true,
Budgets {
idle: 0,
ceiling: 0,
},
false,
);
assert!(off.contains("off of silence or off in total"), "{off:?}");
}
fn flat(line: &str) -> String {
line.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[test]
fn a_long_push_gate_is_told_what_it_is_sitting_on() {
let r = row("cargo-test", 60.0);
let pushing = flat(&beat_line(&r, true, B, true));
assert!(
pushing.contains("A remote may close it first"),
"{pushing:?}"
);
assert!(pushing.contains("Connection reset by peer"), "{pushing:?}");
assert!(
pushing.contains("Moving a gate entry earlier"),
"{pushing:?}"
);
let later = flat(&beat_line(&r, false, B, true));
assert!(!later.contains("close it first"), "{later:?}");
let committing = flat(&beat_line(&r, true, B, false));
assert!(!committing.contains("close it first"), "{committing:?}");
}
#[test]
fn the_push_note_does_not_recommend_ssh_keepalive() {
let r = row("cargo-test", 60.0);
let pushing = flat(&beat_line(&r, true, B, true));
assert!(
!pushing.contains("ServerAlive"),
"keepalive was already on when this failed; recommending it \
would be useless advice: {pushing:?}"
);
assert!(
pushing.contains("ssh keepalive does not prevent this"),
"say so, rather than leaving the reader to try it: {pushing:?}"
);
}
}