1use std::cell::RefCell;
43use std::io::{IsTerminal, Write};
44use std::sync::atomic::{AtomicBool, Ordering};
45use std::sync::{Arc, Mutex, Weak};
46use std::time::Instant;
47
48const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
51
52const MAX_LINES: usize = 12;
55
56struct Slot {
58 name: String,
61 started: Instant,
64 last_output: Instant,
67 next_beat: u64,
69 buf: Vec<u8>,
70 running: bool,
72 done: bool,
73 activity: Option<Arc<crate::hooks::common::Activity>>,
77 queued: Option<(Instant, u64)>,
80}
81
82pub struct Stage {
85 slots: Mutex<Vec<Slot>>,
86 out: Mutex<usize>,
89 live: bool,
91 on_push: bool,
96 stop: AtomicBool,
97}
98
99thread_local! {
100 static SINK: RefCell<Option<(Arc<Stage>, usize)>> = const { RefCell::new(None) };
102}
103
104impl Stage {
105 pub fn begin(settings: &crate::config::Settings, names: &[&str]) -> Arc<Stage> {
108 let now = Instant::now();
109 let stage = Arc::new(Stage {
110 slots: Mutex::new(
111 names
112 .iter()
113 .map(|n| Slot {
114 name: crate::ui::sanitize(
118 n.strip_prefix("pre-commit-")
119 .or_else(|| n.strip_prefix("pre-push-"))
120 .unwrap_or(n),
121 ),
122 started: now,
123 last_output: now,
124 next_beat: HEARTBEAT_SECS,
125 buf: Vec::new(),
126 running: false,
127 done: false,
128 activity: None,
129 queued: None,
130 })
131 .collect(),
132 ),
133 out: Mutex::new(0),
134 live: enabled(settings) && watching(),
135 on_push: names.iter().any(|n| n.starts_with("pre-push-")),
139 stop: AtomicBool::new(false),
140 });
141 if stage.live {
142 let weak = Arc::downgrade(&stage);
145 let own = settings.for_thread();
146 let _ = std::thread::Builder::new()
147 .name("amont-live".into())
148 .spawn(move || tick(own, weak));
149 } else if enabled(settings) {
150 let weak = Arc::downgrade(&stage);
156 let own = settings.for_thread();
157 let _ = std::thread::Builder::new()
158 .name("amont-heartbeat".into())
159 .spawn(move || heartbeat(own, weak));
160 }
161 stage
162 }
163
164 pub fn enter(self: &Arc<Stage>, idx: usize) -> SinkGuard {
168 {
169 let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
170 if let Some(slot) = slots.get_mut(idx) {
171 slot.running = true;
172 slot.started = Instant::now();
173 slot.last_output = slot.started;
174 slot.next_beat = HEARTBEAT_SECS;
175 }
176 }
177 SINK.with(|s| *s.borrow_mut() = Some((Arc::clone(self), idx)));
178 SinkGuard
179 }
180
181 pub fn queue(&self, idx: usize, since: Option<(Instant, u64)>) {
183 let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
184 if let Some(slot) = slots.get_mut(idx) {
185 slot.queued = since;
186 }
187 }
188
189 pub fn attach(
193 self: &Arc<Stage>,
194 idx: usize,
195 activity: Arc<crate::hooks::common::Activity>,
196 ) -> AttachGuard {
197 let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
198 if let Some(slot) = slots.get_mut(idx) {
199 slot.activity = Some(activity);
200 }
201 AttachGuard {
202 stage: Arc::clone(self),
203 idx,
204 }
205 }
206
207 pub fn append_raw(&self, idx: usize, bytes: &[u8]) {
209 let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
210 if let Some(slot) = slots.get_mut(idx) {
211 if !slot.done {
212 slot.buf.extend_from_slice(bytes);
213 slot.last_output = Instant::now();
214 }
215 }
216 }
217
218 fn append_line(&self, idx: usize, line: &str) {
219 let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
220 if let Some(slot) = slots.get_mut(idx) {
221 if !slot.done {
222 slot.buf.extend_from_slice(line.as_bytes());
223 slot.buf.push(b'\n');
224 slot.last_output = Instant::now();
225 }
226 }
227 }
228
229 pub fn finish(&self, settings: &crate::config::Settings, idx: usize) {
237 let block = {
238 let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
239 let Some(slot) = slots.get_mut(idx) else {
240 return;
241 };
242 slot.done = true;
243 slot.running = false;
244 std::mem::take(&mut slot.buf)
245 };
246 if block.is_empty() && !self.live {
247 return;
248 }
249 let mut drawn = self.out.lock().unwrap_or_else(|p| p.into_inner());
250 if !block.is_empty() {
251 if *drawn > 0 {
252 let mut err = std::io::stderr().lock();
253 let _ = write!(err, "\x1b[{}A\x1b[J", *drawn);
254 let _ = err.flush();
255 *drawn = 0;
256 }
257 let stdout = std::io::stdout();
258 let mut handle = stdout.lock();
259 let _ = handle.write_all(&block);
260 let _ = handle.flush();
261 }
262 self.repaint(settings, &mut drawn);
263 }
264
265 fn repaint(&self, settings: &crate::config::Settings, drawn: &mut usize) {
268 if !self.live {
269 return;
270 }
271 let entries: Vec<Row> = {
272 let slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
273 let now = Instant::now();
274 slots
275 .iter()
276 .filter(|s| s.running && !s.done)
277 .map(|s| row_of(s, now, now.duration_since(s.started).as_secs_f64()))
278 .collect()
279 };
280 let text = region(&entries, term_width(), budgets(settings));
281 let mut paint = String::new();
282 if *drawn > 0 {
283 paint.push_str(&format!("\x1b[{}A\x1b[J", *drawn));
284 }
285 paint.push_str(&text);
286 if paint.is_empty() {
287 return;
288 }
289 let mut err = std::io::stderr().lock();
290 let _ = err.write_all(paint.as_bytes());
291 let _ = err.flush();
292 *drawn = text.matches('\n').count();
293 }
294}
295
296impl Drop for Stage {
297 fn drop(&mut self) {
302 self.stop.store(true, Ordering::Relaxed);
303 if !self.live {
304 return;
305 }
306 let drawn = self.out.get_mut().unwrap_or_else(|p| p.into_inner());
307 if *drawn > 0 {
308 let mut err = std::io::stderr().lock();
309 let _ = write!(err, "\x1b[{}A\x1b[J", *drawn);
310 let _ = err.flush();
311 *drawn = 0;
312 }
313 }
314}
315
316fn tick(settings: crate::config::Settings, weak: Weak<Stage>) {
322 loop {
323 std::thread::sleep(std::time::Duration::from_millis(80));
324 let Some(stage) = weak.upgrade() else { return };
325 if stage.stop.load(Ordering::Relaxed) {
326 return;
327 }
328 let mut drawn = stage.out.lock().unwrap_or_else(|p| p.into_inner());
329 stage.repaint(&settings, &mut drawn);
330 }
331}
332
333#[derive(Debug, Clone)]
335pub struct Row {
336 pub name: String,
337 pub elapsed: f64,
339 pub quiet: f64,
341 pub still: f64,
344 pub cpu: RowCpu,
345 pub wait: Option<(crate::hooks::wait::WaitKind, f64)>,
349 pub load: Option<(u64, crate::load::Load, u32)>,
353 pub queued: Option<(f64, u64)>,
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360pub enum RowCpu {
361 None,
364 NotSampled,
368 Unmeasured,
371 Busy(u32),
373 Idle,
375}
376
377fn row_of(s: &Slot, now: Instant, elapsed: f64) -> Row {
381 use crate::hooks::common::{CpuState, BUSY_MILLI_CORES};
382 let slot_quiet = now.duration_since(s.last_output).as_secs_f64();
383 let (quiet, still, cpu, wait, load) = match &s.activity {
384 None => (slot_quiet, slot_quiet, RowCpu::None, None, None),
385 Some(a) => {
386 let quiet = slot_quiet.min(a.quiet_for().as_secs_f64());
387 let still = quiet.min(a.still_for().as_secs_f64());
388 let cpu = match (a.cpu_state(), a.fresh_rate()) {
389 (CpuState::Off, _) => RowCpu::NotSampled,
390 (_, Some(r)) if r >= BUSY_MILLI_CORES => RowCpu::Busy(r),
391 (_, Some(_)) => RowCpu::Idle,
392 (_, None) => RowCpu::Unmeasured,
393 };
394 let wait = a.waiting().map(|(k, d)| (k, d.as_secs_f64()));
395 (quiet, still, cpu, wait, a.load_scale())
396 }
397 };
398 Row {
399 name: s.name.clone(),
400 elapsed,
401 quiet,
402 still,
403 cpu,
404 wait,
405 load,
406 queued: s
407 .queued
408 .map(|(since, n)| (now.duration_since(since).as_secs_f64(), n)),
409 }
410}
411
412impl Row {
413 fn applied_idle(&self, budgets: Budgets) -> u64 {
416 match self.load {
417 Some((applied, _, _)) if applied > 0 => applied,
418 _ => budgets.idle,
419 }
420 }
421}
422
423#[derive(Debug, Clone, Copy)]
427pub struct Budgets {
428 pub idle: u64,
429 pub ceiling: u64,
430 pub lock_wait: u64,
431 pub extended: u64,
434}
435
436fn budgets(settings: &crate::config::Settings) -> Budgets {
437 let idle = crate::hooks::common::idle_timeout(settings);
438 Budgets {
439 idle,
440 extended: idle.saturating_mul(crate::hooks::common::idle_load_scale(settings)),
441 ceiling: crate::hooks::common::check_timeout(settings),
442 lock_wait: match crate::hooks::common::lock_wait(settings) {
443 crate::hooks::common::LockWait::Secs(s) => s,
444 crate::hooks::common::LockWait::UntilCeiling => 0,
445 },
446 }
447}
448
449const QUIET_NOTE_SECS: f64 = 30.0;
453
454const HEARTBEAT_SECS: u64 = 60;
456
457fn elapsed_column(secs: f64) -> String {
461 if secs < 60.0 {
462 format!("{secs:>5.1}s")
463 } else {
464 format!("{:>6}", crate::hooks::common::human_secs(secs as u64))
465 }
466}
467
468fn region(entries: &[Row], width: usize, budgets: Budgets) -> String {
477 if entries.is_empty() {
478 return String::new();
479 }
480 let pad = entries
481 .iter()
482 .take(MAX_LINES)
483 .map(|r| r.name.chars().count())
484 .max()
485 .unwrap_or(0);
486 let mut out = String::new();
487 for row in entries.iter().take(MAX_LINES) {
488 let frame = FRAMES[((row.elapsed * 10.0) as usize) % FRAMES.len()];
489 let name = &row.name;
490 let mut line = format!("{frame} {name:<pad$} {}", elapsed_column(row.elapsed));
491 if let Some((secs, n)) = row.queued {
492 line.push_str(&format!(
493 " · queued {} (slots {n}/{n})",
494 crate::hooks::common::human_secs(secs as u64)
495 ));
496 } else if let Some((kind, waited)) = row.wait {
497 line.push_str(&format!(
500 " · {} {}",
501 kind.short(),
502 crate::hooks::common::human_secs(waited as u64)
503 ));
504 if budgets.lock_wait > 0 {
505 line.push_str(&format!(
506 "/{}",
507 crate::hooks::common::human_secs(budgets.lock_wait)
508 ));
509 }
510 } else if row.quiet >= QUIET_NOTE_SECS {
511 let quiet = crate::hooks::common::human_secs(row.quiet as u64);
512 match row.cpu {
513 RowCpu::Busy(m) => line.push_str(&format!(
515 " · quiet {quiet} · {}",
516 crate::hooks::common::cores(m)
517 )),
518 _ if budgets.idle == 0 => line.push_str(&format!(" · quiet {quiet}")),
519 RowCpu::Unmeasured => line.push_str(&format!(
525 " · quiet {quiet}/{} (CPU unmeasured)",
526 crate::hooks::common::human_secs(budgets.extended.max(budgets.idle))
527 )),
528 RowCpu::Idle if row.quiet - row.still >= 1.0 => line.push_str(&format!(
529 " · quiet {quiet} · idle {}/{}",
530 crate::hooks::common::human_secs(row.still as u64),
531 crate::hooks::common::human_secs(row.applied_idle(budgets))
532 )),
533 _ => line.push_str(&format!(
534 " · quiet {quiet}/{}",
535 crate::hooks::common::human_secs(row.applied_idle(budgets))
536 )),
537 }
538 if let (Some((_, _, factor)), false) = (row.load, row.cpu == RowCpu::Unmeasured) {
539 line.push_str(&format!(" (load {})", crate::load::factor_text(factor)));
540 }
541 }
542 if budgets.ceiling > 0 && row.elapsed >= 0.8 * budgets.ceiling as f64 {
543 line.push_str(&format!(
544 " · {}/{}",
545 crate::hooks::common::human_secs(row.elapsed as u64),
546 crate::hooks::common::human_secs(budgets.ceiling)
547 ));
548 }
549 if line.chars().count() > width {
550 out.extend(line.chars().take(width));
551 } else {
552 out.push_str(&line);
553 }
554 out.push('\n');
555 }
556 if entries.len() > MAX_LINES {
557 out.push_str(&format!("… and {} more\n", entries.len() - MAX_LINES));
558 }
559 out
560}
561
562fn heartbeat(settings: crate::config::Settings, weak: Weak<Stage>) {
573 loop {
574 std::thread::sleep(std::time::Duration::from_secs(1));
575 let Some(stage) = weak.upgrade() else { return };
576 if stage.stop.load(Ordering::Relaxed) {
577 return;
578 }
579 let due: Vec<(Row, bool)> = {
580 let mut slots = stage.slots.lock().unwrap_or_else(|p| p.into_inner());
581 let now = Instant::now();
582 let mut due = Vec::new();
583 for s in slots.iter_mut().filter(|s| s.running && !s.done) {
584 let elapsed = now.duration_since(s.started).as_secs();
585 if elapsed >= s.next_beat {
586 let first = s.next_beat == HEARTBEAT_SECS;
587 s.next_beat += HEARTBEAT_SECS;
588 due.push((row_of(s, now, elapsed as f64), first));
589 }
590 }
591 due
592 };
593 if due.is_empty() {
594 continue;
595 }
596 let text: String = due
597 .iter()
598 .map(|(row, first)| beat_line(row, *first, budgets(&settings), stage.on_push))
599 .collect();
600 let _guard = stage.out.lock().unwrap_or_else(|p| p.into_inner());
601 let mut err = std::io::stderr().lock();
602 let _ = err.write_all(text.as_bytes());
603 let _ = err.flush();
604 }
605}
606
607fn beat_line(row: &Row, first: bool, budgets: Budgets, on_push: bool) -> String {
630 use crate::hooks::common::human_secs;
631 let mut line = format!(
634 " … {} still running: {}, last output {} ago",
635 row.name,
636 human_secs(row.elapsed as u64),
637 human_secs(row.quiet as u64)
638 );
639 if let Some((secs, n)) = row.queued {
640 line.push_str(&format!(
641 ", queued {} for a host slot (amont.hostSlots {n})",
642 human_secs(secs as u64)
643 ));
644 } else if let Some((kind, waited)) = row.wait {
645 line.push_str(&format!(
646 ", waiting for {} {}",
647 kind.describe(),
648 human_secs(waited as u64)
649 ));
650 match budgets.lock_wait {
651 0 => line.push_str(" (amont.lockWait 0: until the ceiling)"),
652 s => line.push_str(&format!(" (amont.lockWait {})", human_secs(s))),
653 }
654 } else {
655 match row.cpu {
656 RowCpu::Busy(m) => line.push_str(&format!(", busy {}", crate::hooks::common::cores(m))),
657 RowCpu::Idle => line.push_str(&format!(", CPU idle {}", human_secs(row.still as u64))),
658 RowCpu::Unmeasured if budgets.idle > 0 => line.push_str(&format!(
659 ", CPU unmeasured — extended budget {}",
660 human_secs(budgets.extended.max(budgets.idle))
661 )),
662 RowCpu::Unmeasured => line.push_str(", CPU unmeasured"),
663 RowCpu::None | RowCpu::NotSampled => {}
664 }
665 if let (Some((applied, load, factor)), false) = (row.load, row.cpu == RowCpu::Unmeasured) {
666 line.push_str(&format!(
667 ", budget {} (load avg {} on {} cores, {} — amont.idleLoadScale)",
668 human_secs(applied),
669 load.avg1_text(),
670 load.cores,
671 crate::load::factor_text(factor)
672 ));
673 }
674 }
675 if first {
676 let idle = match row.applied_idle(budgets) {
677 0 => "off".to_string(),
678 s => human_secs(s),
679 };
680 let ceiling = match budgets.ceiling {
681 0 => "off".to_string(),
682 s => human_secs(s),
683 };
684 match row.cpu {
685 _ if row.wait.is_some() => line.push_str(&format!(
688 " (killed after {} in the wait, or {ceiling} in total — amont.lockWait / \
689 amont.timeout)",
690 match budgets.lock_wait {
691 0 => ceiling.clone(),
692 s => human_secs(s),
693 }
694 )),
695 RowCpu::Unmeasured if budgets.idle > 0 => line.push_str(&format!(
698 " (killed after {} with no output while its CPU is unmeasured, or {ceiling} \
699 in total — amont.idleTimeout × amont.idleLoadScale / amont.timeout)",
700 human_secs(budgets.extended.max(budgets.idle))
701 )),
702 RowCpu::Busy(_) | RowCpu::Idle if budgets.idle > 0 => line.push_str(&format!(
703 " (killed after {idle} with no output and under 0.1 core of CPU, or \
704 {ceiling} in total — amont.idleTimeout / amont.timeout)"
705 )),
706 _ => line.push_str(&format!(
707 " (killed after {idle} of silence or {ceiling} in total — amont.idleTimeout / amont.timeout)"
708 )),
709 }
710 if row.cpu == RowCpu::NotSampled && budgets.idle > 0 {
711 line.push_str("; CPU not sampled here, silence alone counts");
712 }
713 if on_push {
714 line.push_str(concat!(
719 "\n git opened its connection to the remote before calling this",
720 "\n gate, and it stays idle until the gate finishes. A remote may",
721 "\n close it first — GitHub does — and the push then fails with",
722 "\n \"Connection reset by peer\", naming the network rather than the",
723 "\n wait. ssh keepalive does not prevent this.",
724 "\n Declaring this check at pre-commit moves it off the push path —",
725 "\n see \"Moving a gate entry earlier\" in the docs.",
726 ));
727 }
728 }
729 line.push('\n');
730 line
731}
732
733pub fn term_width() -> usize {
742 std::env::var("COLUMNS")
743 .ok()
744 .and_then(|c| c.parse::<usize>().ok())
745 .filter(|w| *w >= 20)
746 .unwrap_or(80)
747}
748
749pub struct FinishOnDrop<'a> {
753 stage: &'a Stage,
754 idx: usize,
755 settings: &'a crate::config::Settings,
758}
759
760impl<'a> FinishOnDrop<'a> {
761 pub fn new(
762 settings: &'a crate::config::Settings,
763 stage: &'a Stage,
764 idx: usize,
765 ) -> FinishOnDrop<'a> {
766 FinishOnDrop {
767 stage,
768 idx,
769 settings,
770 }
771 }
772}
773
774impl Drop for FinishOnDrop<'_> {
775 fn drop(&mut self) {
776 self.stage.finish(self.settings, self.idx);
777 }
778}
779
780pub struct SinkGuard;
782
783impl Drop for SinkGuard {
784 fn drop(&mut self) {
785 SINK.with(|s| *s.borrow_mut() = None);
786 }
787}
788
789pub struct AttachGuard {
792 stage: Arc<Stage>,
793 idx: usize,
794}
795
796impl Drop for AttachGuard {
797 fn drop(&mut self) {
798 let mut slots = self.stage.slots.lock().unwrap_or_else(|p| p.into_inner());
799 if let Some(slot) = slots.get_mut(self.idx) {
800 slot.activity = None;
801 }
802 }
803}
804
805pub fn current_sink() -> Option<(Arc<Stage>, usize)> {
808 SINK.with(|s| s.borrow().clone())
809}
810
811pub fn say(line: &str) {
817 let routed = SINK.with(|s| {
818 s.borrow().as_ref().map(|(stage, idx)| {
819 stage.append_line(*idx, line);
820 })
821 });
822 if routed.is_none() {
823 println!("{line}");
824 }
825}
826
827#[macro_export]
832macro_rules! say {
833 ($($arg:tt)*) => {
834 $crate::live::say(&format!($($arg)*))
835 };
836}
837
838pub fn quiet(settings: &crate::config::Settings) -> bool {
863 *settings.quiet.get_or_init(|| {
864 decide(
865 crate::config::enumerated_or(settings, "amont.quiet", QUIET_VALUES, "auto"),
866 watching(),
867 )
868 })
869}
870
871pub const QUIET_VALUES: &[&str] = &["never", "auto", "always"];
872
873fn decide(setting: &str, watching: bool) -> bool {
875 match setting {
876 "always" => true,
877 "auto" => !watching,
878 _ => false,
881 }
882}
883
884pub fn enabled(settings: &crate::config::Settings) -> bool {
887 *settings
888 .progress
889 .get_or_init(|| crate::config::boolean_or(settings, "amont.progress", true))
890}
891
892pub fn watching() -> bool {
898 static WATCHING: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
899 *WATCHING.get_or_init(|| {
900 if !std::io::stderr().is_terminal() {
901 return false;
902 }
903 match std::env::var("TERM") {
904 Ok(term) => term != "dumb",
905 Err(_) => !cfg!(windows),
906 }
907 })
908}
909
910#[cfg(test)]
911mod tests {
912 use super::*;
913
914 fn test_settings() -> crate::config::Settings {
915 crate::config::Settings::default()
916 }
917
918 #[test]
919 fn quiet_asks_who_is_reading() {
920 assert!(!decide("never", true));
921 assert!(!decide("never", false));
922 assert!(always_and_auto_agree_at_a_terminal());
923 assert!(decide("auto", false), "captured: nobody is watching");
924 assert!(decide("always", true));
925 assert!(decide("always", false));
926 assert!(!decide("shhh", false));
929 }
930
931 fn always_and_auto_agree_at_a_terminal() -> bool {
932 !decide("auto", true) && decide("always", true)
933 }
934
935 #[test]
939 fn slots_do_not_share_a_buffer() {
940 let stage = Stage::begin(&test_settings(), &["a", "b"]);
941 std::thread::scope(|scope| {
942 for idx in 0..2 {
943 let stage = Arc::clone(&stage);
944 scope.spawn(move || {
945 let _guard = stage.enter(idx);
946 for i in 0..50 {
947 say(&format!("check-{idx} line-{i}"));
948 std::thread::yield_now();
949 }
950 });
951 }
952 });
953 let slots = stage.slots.lock().unwrap();
954 for idx in 0..2 {
955 let text = String::from_utf8(slots[idx].buf.clone()).unwrap();
956 assert_eq!(text.lines().count(), 50);
957 assert!(
958 text.lines()
959 .all(|l| l.starts_with(&format!("check-{idx} "))),
960 "a foreign line landed in slot {idx}"
961 );
962 }
963 }
964
965 #[test]
967 fn no_sink_means_no_capture() {
968 let stage = Stage::begin(&test_settings(), &["a"]);
969 say("goes to stdout, not to a slot");
970 let slots = stage.slots.lock().unwrap();
971 assert!(slots[0].buf.is_empty());
972 }
973
974 #[test]
977 fn a_finished_slot_takes_no_more_writes() {
978 let stage = Stage::begin(&test_settings(), &["a"]);
979 stage.append_raw(0, b"before\n");
980 stage.finish(&test_settings(), 0);
981 stage.append_raw(0, b"after\n");
982 let slots = stage.slots.lock().unwrap();
983 assert!(slots[0].buf.is_empty(), "a write landed after finish");
984 }
985
986 #[test]
989 fn a_slot_name_is_sanitised_at_begin() {
990 let stage = Stage::begin(&test_settings(), &["evil\u{1b}[2Jname\rhere"]);
991 let slots = stage.slots.lock().unwrap();
992 assert!(!slots[0].name.contains('\u{1b}'), "{:?}", slots[0].name);
993 assert!(!slots[0].name.contains('\r'), "{:?}", slots[0].name);
994 }
995
996 #[test]
999 fn a_slot_name_drops_the_stage_prefix() {
1000 let stage = Stage::begin(
1001 &test_settings(),
1002 &["pre-commit-clippy", "pre-push-run-tests", "bare"],
1003 );
1004 let slots = stage.slots.lock().unwrap();
1005 assert_eq!(slots[0].name, "clippy");
1006 assert_eq!(slots[1].name, "run-tests");
1007 assert_eq!(slots[2].name, "bare");
1008 }
1009
1010 fn row(name: &str, elapsed: f64) -> Row {
1011 Row {
1012 name: name.into(),
1013 elapsed,
1014 quiet: 0.0,
1015 still: 0.0,
1016 cpu: RowCpu::None,
1017 wait: None,
1018 load: None,
1019 queued: None,
1020 }
1021 }
1022
1023 #[test]
1026 fn a_queued_check_says_it_waits_for_a_host_slot() {
1027 let mut r = row("pre-push-run-tests-js", 42.0);
1028 r.queued = Some((42.0, 2));
1029 let text = region(&[r.clone()], 80, B);
1030 assert!(text.contains("· queued 42s (slots 2/2)"), "{text:?}");
1031 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1032 r.elapsed = 60.0;
1033 r.quiet = 60.0;
1034 r.queued = Some((60.0, 2));
1035 let beat = beat_line(&r, false, B, false);
1036 assert_eq!(
1037 beat,
1038 " … pre-push-run-tests-js still running: 1m00s, last output 1m00s ago, \
1039 queued 1m00s for a host slot (amont.hostSlots 2)\n"
1040 );
1041 }
1042
1043 #[test]
1047 fn a_load_stretched_budget_is_shown_with_its_load() {
1048 let mut r = row("pre-push-run-tests-js", 240.0);
1049 r.quiet = 130.0;
1050 r.still = 130.0;
1051 r.cpu = RowCpu::Idle;
1052 r.load = Some((
1053 468,
1054 crate::load::Load {
1055 avg1_milli: 31_200,
1056 cores: 8,
1057 },
1058 3900,
1059 ));
1060 let text = region(&[r.clone()], 80, B);
1061 assert!(text.contains("· quiet 2m10s/7m48s (load ×3.9)"), "{text:?}");
1062 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1063 let beat = beat_line(&r, false, B, false);
1064 assert!(
1065 beat.ends_with(
1066 ", CPU idle 2m10s, budget 7m48s (load avg 31.2 on 8 cores, ×3.9 — amont.idleLoadScale)\n"
1067 ),
1068 "{beat:?}"
1069 );
1070 let first = flat(&beat_line(&r, true, B, false));
1071 assert!(
1072 first.contains("killed after 7m48s with no output and under 0.1 core of CPU"),
1073 "{first:?}"
1074 );
1075 }
1076
1077 const B: Budgets = Budgets {
1078 idle: 120,
1079 ceiling: 3600,
1080 lock_wait: 600,
1081 extended: 480,
1082 };
1083
1084 #[test]
1088 fn an_unmeasured_check_is_shown_against_the_extended_budget() {
1089 let mut r = row("pre-push-run-tests-js", 240.0);
1090 r.quiet = 130.0;
1091 r.still = 130.0;
1092 r.cpu = RowCpu::Unmeasured;
1093 let text = region(&[r.clone()], 80, B);
1094 assert!(
1095 text.contains("· quiet 2m10s/8m00s (CPU unmeasured)"),
1096 "{text:?}"
1097 );
1098 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1099 let beat = beat_line(&r, false, B, false);
1100 assert!(
1101 beat.ends_with(", CPU unmeasured — extended budget 8m00s\n"),
1102 "{beat:?}"
1103 );
1104 let first = flat(&beat_line(&r, true, B, false));
1105 assert!(
1106 first.contains(
1107 "killed after 8m00s with no output while its CPU is unmeasured, or 1h00m in total"
1108 ),
1109 "{first:?}"
1110 );
1111 assert!(first.contains("amont.idleLoadScale"), "{first:?}");
1112 }
1113
1114 #[test]
1118 fn a_declared_wait_is_shown_against_its_own_budget() {
1119 use crate::hooks::wait::{CargoLockWhat, WaitKind};
1120 let mut r = row("pre-push-run-tests-js", 240.0);
1121 r.quiet = 130.0;
1122 r.still = 0.0;
1123 r.cpu = RowCpu::Unmeasured;
1124 r.wait = Some((WaitKind::CargoLock(CargoLockWhat::BuildDirectory), 90.0));
1125 let text = region(&[r.clone()], 80, B);
1126 assert!(text.contains("· cargo lock 1m30s/10m00s"), "{text:?}");
1127 assert!(!text.contains("quiet"), "no silence countdown: {text:?}");
1128 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1129 let until = Budgets { lock_wait: 0, ..B };
1130 let text = region(&[r.clone()], 80, until);
1131 assert!(
1132 text.contains("· cargo lock 1m30s") && !text.contains("1m30s/"),
1133 "{text:?}"
1134 );
1135
1136 let beat = beat_line(&r, false, B, false);
1137 assert_eq!(
1138 beat,
1139 " … pre-push-run-tests-js still running: 4m00s, last output 2m10s ago, \
1140 waiting for the cargo lock on the build directory 1m30s (amont.lockWait 10m00s)\n"
1141 );
1142 let beat = beat_line(&r, false, until, false);
1143 assert!(
1144 beat.contains("(amont.lockWait 0: until the ceiling)"),
1145 "{beat:?}"
1146 );
1147 let first = flat(&beat_line(&r, true, B, false));
1150 assert!(
1151 first.contains("(killed after 10m00s in the wait, or 1h00m in total — amont.lockWait"),
1152 "{first:?}"
1153 );
1154 assert!(!first.contains("unmeasured"), "{first:?}");
1155 }
1156
1157 #[test]
1160 fn frames_advance_with_time() {
1161 let a = region(&[row("clippy", 0.0)], 80, B);
1162 let b = region(&[row("clippy", 0.1)], 80, B);
1163 let c = region(&[row("clippy", 1.0)], 80, B);
1164 assert_ne!(a.chars().next(), b.chars().next());
1165 assert_eq!(a.chars().next(), c.chars().next(), "10 frames per second");
1166 }
1167
1168 #[test]
1171 fn region_lines_align() {
1172 let text = region(&[row("a", 0.0), row("longer-name", 0.0)], 80, B);
1173 let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
1174 assert_eq!(widths[0], widths[1], "{text:?}");
1175 let text = region(&[row("a", 3.2), row("b", 492.0)], 80, B);
1176 let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
1177 assert_eq!(widths[0], widths[1], "{text:?}");
1178 assert!(text.contains("8m12s"), "{text:?}");
1179 }
1180
1181 #[test]
1183 fn region_caps_and_counts_the_rest() {
1184 let entries: Vec<Row> = (0..13).map(|i| row(&format!("check-{i}"), 0.0)).collect();
1185 let text = region(&entries, 80, B);
1186 assert_eq!(text.lines().count(), MAX_LINES + 1);
1187 assert!(text.ends_with("… and 1 more\n"), "{text:?}");
1188 }
1189
1190 #[test]
1193 fn region_respects_width() {
1194 let text = region(&[row("a-name-much-longer-than-the-terminal", 0.0)], 20, B);
1195 assert!(text.lines().all(|l| l.chars().count() <= 20), "{text:?}");
1196 }
1197
1198 #[test]
1200 fn an_empty_region_is_empty() {
1201 assert_eq!(region(&[], 80, B), "");
1202 }
1203
1204 #[test]
1208 fn a_quiet_check_shows_its_silence_against_the_budget() {
1209 let mut r = row("cargo-test", 300.0);
1210 r.quiet = 5.0;
1211 assert!(!region(&[r.clone()], 80, B).contains("quiet"));
1212 r.quiet = 45.0;
1213 let text = region(&[r.clone()], 80, B);
1214 assert!(text.contains("quiet 45s/2m00s"), "{text:?}");
1215 let off = Budgets { idle: 0, ..B };
1216 let text = region(&[r], 80, off);
1217 assert!(
1218 text.contains("quiet 45s") && !text.contains('/'),
1219 "{text:?}"
1220 );
1221 }
1222
1223 #[test]
1228 fn a_quiet_busy_check_shows_cores_and_an_idle_one_counts_down_the_still_time() {
1229 let mut r = row("vitest-workspace", 240.0);
1230 r.quiet = 130.0;
1231 r.still = 130.0;
1232 r.cpu = RowCpu::Busy(3900);
1233 let busy = region(&[r.clone()], 80, B);
1234 assert!(busy.contains("· quiet 2m10s · ~3.9 cores"), "{busy:?}");
1235 assert!(
1236 !busy.contains("/2m00s"),
1237 "no countdown while busy: {busy:?}"
1238 );
1239
1240 r.cpu = RowCpu::Idle;
1241 r.still = 40.0;
1242 let idle = region(&[r.clone()], 80, B);
1243 assert!(idle.contains("· quiet 2m10s · idle 40s/2m00s"), "{idle:?}");
1244
1245 r.cpu = RowCpu::Unmeasured;
1246 r.still = 130.0;
1247 let plain = region(&[r], 80, B);
1248 assert!(
1249 plain.contains("· quiet 2m10s/8m00s (CPU unmeasured)"),
1250 "{plain:?}"
1251 );
1252
1253 for text in [busy, idle, plain] {
1254 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1255 }
1256 }
1257
1258 #[test]
1261 fn a_heartbeat_appends_the_cpu_state_after_an_unchanged_prefix() {
1262 let mut r = row("vitest", 240.0);
1263 r.quiet = 130.0;
1264 r.still = 40.0;
1265 let prefix = " … vitest still running: 4m00s, last output 2m10s ago";
1266 for (cpu, suffix) in [
1267 (RowCpu::Busy(3900), ", busy ~3.9 cores\n"),
1268 (RowCpu::Idle, ", CPU idle 40s\n"),
1269 (
1270 RowCpu::Unmeasured,
1271 ", CPU unmeasured — extended budget 8m00s\n",
1272 ),
1273 (RowCpu::None, "\n"),
1274 (RowCpu::NotSampled, "\n"),
1275 ] {
1276 r.cpu = cpu;
1277 assert_eq!(beat_line(&r, false, B, false), format!("{prefix}{suffix}"));
1278 }
1279 }
1280
1281 #[test]
1283 fn the_first_beat_states_the_rule_in_force() {
1284 let mut r = row("vitest", 60.0);
1285 r.cpu = RowCpu::Idle;
1286 let sampled = beat_line(&r, true, B, false);
1287 assert!(
1288 flat(&sampled).contains(
1289 "killed after 2m00s with no output and under 0.1 core of CPU, or 1h00m in total"
1290 ),
1291 "{sampled:?}"
1292 );
1293 r.cpu = RowCpu::NotSampled;
1294 let not = beat_line(&r, true, B, false);
1295 assert!(
1296 not.contains("2m00s of silence or 1h00m in total"),
1297 "{not:?}"
1298 );
1299 assert!(
1300 not.contains("CPU not sampled here, silence alone counts"),
1301 "{not:?}"
1302 );
1303 }
1304
1305 #[test]
1308 fn the_ceiling_shows_only_when_it_is_near() {
1309 assert!(!region(&[row("cargo-test", 1000.0)], 80, B).contains("/1h00m"));
1310 let text = region(&[row("cargo-test", 3000.0)], 80, B);
1311 assert!(text.contains("50m00s/1h00m"), "{text:?}");
1312 let off = Budgets { ceiling: 0, ..B };
1313 assert!(!region(&[row("cargo-test", 3000.0)], 80, off).contains("/"));
1314 }
1315
1316 #[test]
1320 fn a_heartbeat_names_the_budgets_once() {
1321 let mut r = row("cargo-test", 60.0);
1322 r.quiet = 2.0;
1323 let first = beat_line(&r, true, B, false);
1324 assert!(
1325 first.contains("cargo-test still running: 1m00s"),
1326 "{first:?}"
1327 );
1328 assert!(first.contains("last output 2s ago"), "{first:?}");
1329 assert!(
1330 first.contains("2m00s of silence or 1h00m in total"),
1331 "{first:?}"
1332 );
1333 assert!(first.contains("amont.idleTimeout"), "{first:?}");
1334 let later = beat_line(&r, false, B, false);
1335 assert!(!later.contains("amont.idleTimeout"), "{later:?}");
1336 let off = beat_line(
1337 &r,
1338 true,
1339 Budgets {
1340 idle: 0,
1341 ceiling: 0,
1342 lock_wait: 0,
1343 extended: 0,
1344 },
1345 false,
1346 );
1347 assert!(off.contains("off of silence or off in total"), "{off:?}");
1348 }
1349
1350 fn flat(line: &str) -> String {
1357 line.split_whitespace().collect::<Vec<_>>().join(" ")
1358 }
1359
1360 #[test]
1367 fn a_long_push_gate_is_told_what_it_is_sitting_on() {
1368 let r = row("cargo-test", 60.0);
1369
1370 let pushing = flat(&beat_line(&r, true, B, true));
1371 assert!(
1372 pushing.contains("A remote may close it first"),
1373 "{pushing:?}"
1374 );
1375 assert!(pushing.contains("Connection reset by peer"), "{pushing:?}");
1376 assert!(
1377 pushing.contains("Moving a gate entry earlier"),
1378 "{pushing:?}"
1379 );
1380
1381 let later = flat(&beat_line(&r, false, B, true));
1383 assert!(!later.contains("close it first"), "{later:?}");
1384
1385 let committing = flat(&beat_line(&r, true, B, false));
1387 assert!(!committing.contains("close it first"), "{committing:?}");
1388 }
1389
1390 #[test]
1399 fn the_push_note_does_not_recommend_ssh_keepalive() {
1400 let r = row("cargo-test", 60.0);
1401 let pushing = flat(&beat_line(&r, true, B, true));
1402 assert!(
1403 !pushing.contains("ServerAlive"),
1404 "keepalive was already on when this failed; recommending it \
1405 would be useless advice: {pushing:?}"
1406 );
1407 assert!(
1408 pushing.contains("ssh keepalive does not prevent this"),
1409 "say so, rather than leaving the reader to try it: {pushing:?}"
1410 );
1411 }
1412}