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 capture: Option<crate::capture::Capture>,
84 runner_exit: Option<i32>,
86 announced: bool,
88}
89
90const ANNOUNCE_SECS: u64 = 5;
93
94pub struct Stage {
97 slots: Mutex<Vec<Slot>>,
98 out: Mutex<usize>,
101 live: bool,
103 on_push: bool,
108 quiet: bool,
111 log_dir: Option<std::path::PathBuf>,
112 named: AtomicBool,
116 stop: AtomicBool,
117}
118
119thread_local! {
120 static SINK: RefCell<Option<(Arc<Stage>, usize)>> = const { RefCell::new(None) };
122}
123
124impl Stage {
125 pub fn begin(settings: &crate::config::Settings, names: &[&str]) -> Arc<Stage> {
128 let now = Instant::now();
129 let stage = Arc::new(Stage {
130 slots: Mutex::new(
131 names
132 .iter()
133 .map(|n| Slot {
134 name: crate::ui::sanitize(
138 n.strip_prefix("pre-commit-")
139 .or_else(|| n.strip_prefix("pre-push-"))
140 .unwrap_or(n),
141 ),
142 started: now,
143 last_output: now,
144 next_beat: HEARTBEAT_SECS,
145 buf: Vec::new(),
146 running: false,
147 done: false,
148 activity: None,
149 queued: None,
150 capture: None,
151 runner_exit: None,
152 announced: false,
153 })
154 .collect(),
155 ),
156 out: Mutex::new(0),
157 live: enabled(settings) && watching(),
158 on_push: names.iter().any(|n| n.starts_with("pre-push-")),
162 quiet: quiet(settings),
163 named: AtomicBool::new(false),
164 log_dir: if quiet(settings) {
165 crate::capture::log_dir()
166 } else {
167 None
168 },
169 stop: AtomicBool::new(false),
170 });
171 if stage.live {
172 let weak = Arc::downgrade(&stage);
175 let own = settings.for_thread();
176 let _ = std::thread::Builder::new()
177 .name("amont-live".into())
178 .spawn(move || tick(own, weak));
179 } else if enabled(settings) || stage.quiet {
180 let weak = Arc::downgrade(&stage);
186 let own = settings.for_thread();
187 let _ = std::thread::Builder::new()
188 .name("amont-heartbeat".into())
189 .spawn(move || heartbeat(own, weak));
190 }
191 stage
192 }
193
194 pub fn enter(self: &Arc<Stage>, idx: usize) -> SinkGuard {
198 {
199 let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
200 if let Some(slot) = slots.get_mut(idx) {
201 slot.running = true;
202 slot.started = Instant::now();
203 slot.last_output = slot.started;
204 slot.next_beat = HEARTBEAT_SECS;
205 if self.quiet {
206 slot.capture = Some(crate::capture::Capture::open(
207 self.log_dir.clone(),
208 &slot.name,
209 ));
210 }
211 }
212 }
213 SINK.with(|s| *s.borrow_mut() = Some((Arc::clone(self), idx)));
214 SinkGuard
215 }
216
217 pub fn queue(&self, idx: usize, since: Option<(Instant, u64)>) {
219 let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
220 if let Some(slot) = slots.get_mut(idx) {
221 slot.queued = since;
222 }
223 }
224
225 pub fn attach(
229 self: &Arc<Stage>,
230 idx: usize,
231 activity: Arc<crate::hooks::common::Activity>,
232 ) -> AttachGuard {
233 let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
234 if let Some(slot) = slots.get_mut(idx) {
235 slot.activity = Some(activity);
236 }
237 AttachGuard {
238 stage: Arc::clone(self),
239 idx,
240 }
241 }
242
243 pub fn append_raw(&self, idx: usize, bytes: &[u8]) {
245 let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
246 if let Some(slot) = slots.get_mut(idx) {
247 if !slot.done {
248 match slot.capture.as_mut() {
249 Some(capture) => capture.take(bytes),
250 None => slot.buf.extend_from_slice(bytes),
251 }
252 slot.last_output = Instant::now();
253 }
254 }
255 }
256
257 pub fn named(&self) {
259 self.named.store(true, Ordering::Relaxed);
260 }
261
262 pub fn runner_exit(&self, idx: usize, code: Option<i32>) {
264 let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
265 if let Some(slot) = slots.get_mut(idx) {
266 slot.runner_exit = code;
267 }
268 }
269
270 fn append_line(&self, idx: usize, line: &str) {
271 let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
272 if let Some(slot) = slots.get_mut(idx) {
273 if !slot.done {
274 slot.buf.extend_from_slice(line.as_bytes());
275 slot.buf.push(b'\n');
276 slot.last_output = Instant::now();
277 }
278 }
279 }
280
281 pub fn finish(
292 &self,
293 settings: &crate::config::Settings,
294 idx: usize,
295 outcome: Option<crate::check::Outcome>,
296 ) {
297 let block = {
298 let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
299 let Some(slot) = slots.get_mut(idx) else {
300 return;
301 };
302 slot.done = true;
303 slot.running = false;
304 let said = std::mem::take(&mut slot.buf);
305 match slot.capture.take() {
306 None => QuietOut::plain(said),
307 Some(capture) => quiet_block(QuietEnd {
308 name: &slot.name,
309 secs: slot.started.elapsed().as_secs_f64(),
310 outcome,
311 said,
312 capture,
313 runner_exit: slot.runner_exit,
314 ci: in_ci(),
315 ok_line: self.named.load(Ordering::Relaxed),
316 }),
317 }
318 };
319 if block.is_empty() && !self.live {
320 return;
321 }
322 let mut drawn = self.out.lock().unwrap_or_else(|p| p.into_inner());
323 if !block.is_empty() {
324 if *drawn > 0 {
325 let mut err = std::io::stderr().lock();
326 let _ = write!(err, "\x1b[{}A\x1b[J", *drawn);
327 let _ = err.flush();
328 *drawn = 0;
329 }
330 let stdout = std::io::stdout();
331 let mut handle = stdout.lock();
332 block.write(&mut handle);
333 let _ = handle.flush();
334 }
335 self.repaint(settings, &mut drawn);
336 }
337
338 fn repaint(&self, settings: &crate::config::Settings, drawn: &mut usize) {
341 if !self.live {
342 return;
343 }
344 let entries: Vec<Row> = {
345 let slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
346 let now = Instant::now();
347 slots
348 .iter()
349 .filter(|s| s.running && !s.done)
350 .map(|s| row_of(s, now, now.duration_since(s.started).as_secs_f64()))
351 .collect()
352 };
353 let text = region(&entries, term_width(), budgets(settings));
354 let mut paint = String::new();
355 if *drawn > 0 {
356 paint.push_str(&format!("\x1b[{}A\x1b[J", *drawn));
357 }
358 paint.push_str(&text);
359 if paint.is_empty() {
360 return;
361 }
362 let mut err = std::io::stderr().lock();
363 let _ = err.write_all(paint.as_bytes());
364 let _ = err.flush();
365 *drawn = text.matches('\n').count();
366 }
367}
368
369impl Drop for Stage {
370 fn drop(&mut self) {
375 self.stop.store(true, Ordering::Relaxed);
376 if !self.live {
377 return;
378 }
379 let drawn = self.out.get_mut().unwrap_or_else(|p| p.into_inner());
380 if *drawn > 0 {
381 let mut err = std::io::stderr().lock();
382 let _ = write!(err, "\x1b[{}A\x1b[J", *drawn);
383 let _ = err.flush();
384 *drawn = 0;
385 }
386 }
387}
388
389fn tick(settings: crate::config::Settings, weak: Weak<Stage>) {
395 loop {
396 std::thread::sleep(std::time::Duration::from_millis(80));
397 let Some(stage) = weak.upgrade() else { return };
398 if stage.stop.load(Ordering::Relaxed) {
399 return;
400 }
401 let mut drawn = stage.out.lock().unwrap_or_else(|p| p.into_inner());
402 stage.repaint(&settings, &mut drawn);
403 }
404}
405
406#[derive(Debug, Clone)]
408pub struct Row {
409 pub name: String,
410 pub elapsed: f64,
412 pub quiet: f64,
414 pub still: f64,
417 pub cpu: RowCpu,
418 pub wait: Option<(crate::hooks::wait::WaitKind, f64)>,
422 pub load: Option<(u64, crate::load::Load, u32)>,
426 pub queued: Option<(f64, u64)>,
429}
430
431#[derive(Debug, Clone, Copy, PartialEq, Eq)]
433pub enum RowCpu {
434 None,
437 NotSampled,
441 Unmeasured,
444 Busy(u32),
446 Idle,
448}
449
450fn row_of(s: &Slot, now: Instant, elapsed: f64) -> Row {
454 use crate::hooks::common::{CpuState, BUSY_MILLI_CORES};
455 let slot_quiet = now.duration_since(s.last_output).as_secs_f64();
456 let (quiet, still, cpu, wait, load) = match &s.activity {
457 None => (slot_quiet, slot_quiet, RowCpu::None, None, None),
458 Some(a) => {
459 let quiet = slot_quiet.min(a.quiet_for().as_secs_f64());
460 let still = quiet.min(a.still_for().as_secs_f64());
461 let cpu = match (a.cpu_state(), a.fresh_rate()) {
462 (CpuState::Off, _) => RowCpu::NotSampled,
463 (_, Some(r)) if r >= BUSY_MILLI_CORES => RowCpu::Busy(r),
464 (_, Some(_)) => RowCpu::Idle,
465 (_, None) => RowCpu::Unmeasured,
466 };
467 let wait = a.waiting().map(|(k, d)| (k, d.as_secs_f64()));
468 (quiet, still, cpu, wait, a.load_scale())
469 }
470 };
471 Row {
472 name: s.name.clone(),
473 elapsed,
474 quiet,
475 still,
476 cpu,
477 wait,
478 load,
479 queued: s
480 .queued
481 .map(|(since, n)| (now.duration_since(since).as_secs_f64(), n)),
482 }
483}
484
485impl Row {
486 fn applied_idle(&self, budgets: Budgets) -> u64 {
489 match self.load {
490 Some((applied, _, _)) if applied > 0 => applied,
491 _ => budgets.idle,
492 }
493 }
494}
495
496#[derive(Debug, Clone, Copy)]
500pub struct Budgets {
501 pub idle: u64,
502 pub ceiling: u64,
503 pub lock_wait: u64,
504 pub extended: u64,
507}
508
509fn budgets(settings: &crate::config::Settings) -> Budgets {
510 let idle = crate::hooks::common::idle_timeout(settings);
511 Budgets {
512 idle,
513 extended: idle.saturating_mul(crate::hooks::common::idle_load_scale(settings)),
514 ceiling: crate::hooks::common::check_timeout(settings),
515 lock_wait: match crate::hooks::common::lock_wait(settings) {
516 crate::hooks::common::LockWait::Secs(s) => s,
517 crate::hooks::common::LockWait::UntilCeiling => 0,
518 },
519 }
520}
521
522const QUIET_NOTE_SECS: f64 = 30.0;
526
527const HEARTBEAT_SECS: u64 = 60;
529
530fn elapsed_column(secs: f64) -> String {
534 if secs < 60.0 {
535 format!("{secs:>5.1}s")
536 } else {
537 format!("{:>6}", crate::hooks::common::human_secs(secs as u64))
538 }
539}
540
541fn region(entries: &[Row], width: usize, budgets: Budgets) -> String {
550 if entries.is_empty() {
551 return String::new();
552 }
553 let pad = entries
554 .iter()
555 .take(MAX_LINES)
556 .map(|r| r.name.chars().count())
557 .max()
558 .unwrap_or(0);
559 let mut out = String::new();
560 for row in entries.iter().take(MAX_LINES) {
561 let frame = FRAMES[((row.elapsed * 10.0) as usize) % FRAMES.len()];
562 let name = &row.name;
563 let mut line = format!("{frame} {name:<pad$} {}", elapsed_column(row.elapsed));
564 if let Some((secs, n)) = row.queued {
565 line.push_str(&format!(
566 " · queued {} (slots {n}/{n})",
567 crate::hooks::common::human_secs(secs as u64)
568 ));
569 } else if let Some((kind, waited)) = row.wait {
570 line.push_str(&format!(
573 " · {} {}",
574 kind.short(),
575 crate::hooks::common::human_secs(waited as u64)
576 ));
577 if budgets.lock_wait > 0 {
578 line.push_str(&format!(
579 "/{}",
580 crate::hooks::common::human_secs(budgets.lock_wait)
581 ));
582 }
583 } else if row.quiet >= QUIET_NOTE_SECS {
584 let quiet = crate::hooks::common::human_secs(row.quiet as u64);
585 match row.cpu {
586 RowCpu::Busy(m) => line.push_str(&format!(
588 " · quiet {quiet} · {}",
589 crate::hooks::common::cores(m)
590 )),
591 _ if budgets.idle == 0 => line.push_str(&format!(" · quiet {quiet}")),
592 RowCpu::Unmeasured => line.push_str(&format!(
598 " · quiet {quiet}/{} (CPU unmeasured)",
599 crate::hooks::common::human_secs(budgets.extended.max(budgets.idle))
600 )),
601 RowCpu::Idle if row.quiet - row.still >= 1.0 => line.push_str(&format!(
602 " · quiet {quiet} · idle {}/{}",
603 crate::hooks::common::human_secs(row.still as u64),
604 crate::hooks::common::human_secs(row.applied_idle(budgets))
605 )),
606 _ => line.push_str(&format!(
607 " · quiet {quiet}/{}",
608 crate::hooks::common::human_secs(row.applied_idle(budgets))
609 )),
610 }
611 if let (Some((_, _, factor)), false) = (row.load, row.cpu == RowCpu::Unmeasured) {
612 line.push_str(&format!(" (load {})", crate::load::factor_text(factor)));
613 }
614 }
615 if budgets.ceiling > 0 && row.elapsed >= 0.8 * budgets.ceiling as f64 {
616 line.push_str(&format!(
617 " · {}/{}",
618 crate::hooks::common::human_secs(row.elapsed as u64),
619 crate::hooks::common::human_secs(budgets.ceiling)
620 ));
621 }
622 if line.chars().count() > width {
623 out.extend(line.chars().take(width));
624 } else {
625 out.push_str(&line);
626 }
627 out.push('\n');
628 }
629 if entries.len() > MAX_LINES {
630 out.push_str(&format!("… and {} more\n", entries.len() - MAX_LINES));
631 }
632 out
633}
634
635struct QuietEnd<'a> {
647 name: &'a str,
648 secs: f64,
649 outcome: Option<crate::check::Outcome>,
650 said: Vec<u8>,
652 capture: crate::capture::Capture,
653 runner_exit: Option<i32>,
654 ci: bool,
655 ok_line: bool,
657}
658
659fn in_ci() -> bool {
662 std::env::var("CI").is_ok_and(|v| !v.is_empty() && v != "false" && v != "0")
663}
664
665fn quiet_block(mut end: QuietEnd) -> QuietOut {
669 let stored = end.capture.finish();
670 let failed = is_failure(end.outcome);
671 let passed = end.outcome == Some(crate::check::Outcome::Passed);
672 if end.capture.seen == 0 {
673 if let Some(p) = stored.path() {
679 let _ = std::fs::remove_file(p);
680 }
681 let mut head = end.said;
682 if failed && end.runner_exit.is_some() {
683 head.extend_from_slice(b"(no output)\n");
684 if let Some(code) = end.runner_exit.filter(|c| *c != 0) {
685 head.extend_from_slice(
686 format!("{} failed, runner exit {code}\n", end.name).as_bytes(),
687 );
688 }
689 }
690 return QuietOut::plain(head);
691 }
692 if passed && !end.ok_line {
693 return QuietOut::plain(end.said);
696 }
697 let path = stored.path().map(|p| p.display().to_string());
698 if passed {
699 let mut out = format!("ok {} {:.1}s", end.name, end.secs);
700 if let (crate::capture::Stored::Saved(_), Some(p)) = (&stored, &path) {
701 out.push_str(&format!(" log={p}"));
702 }
703 out.push('\n');
704 if let Some(note) = stored.note() {
705 out.push_str(¬e);
706 out.push('\n');
707 }
708 return QuietOut::plain(out.into_bytes());
709 }
710 let excerpt = end.capture.acc.finish(path.as_deref().unwrap_or("-"));
711 let mut tail = String::new();
712 if failed {
713 if let Some(code) = end.runner_exit.filter(|c| *c != 0) {
714 tail.push_str(&format!("{} failed, runner exit {code}\n", end.name));
715 }
716 }
717 if let Some(p) = &path {
718 tail.push_str(&format!("full log: {p}\n"));
719 }
720 if let Some(note) = stored.note() {
721 tail.push_str(¬e);
722 tail.push('\n');
723 }
724 match (&stored, end.ci && failed) {
728 (crate::capture::Stored::Saved(p), true) => QuietOut {
729 head: end.said,
730 stream: Some((p.clone(), excerpt)),
731 tail: tail.into_bytes(),
732 },
733 _ => {
734 let mut head = end.said;
735 head.extend_from_slice(excerpt.as_bytes());
736 QuietOut {
737 head,
738 stream: None,
739 tail: tail.into_bytes(),
740 }
741 }
742 }
743}
744
745struct QuietOut {
748 head: Vec<u8>,
749 stream: Option<(std::path::PathBuf, String)>,
750 tail: Vec<u8>,
751}
752
753impl QuietOut {
754 fn plain(head: Vec<u8>) -> QuietOut {
755 QuietOut {
756 head,
757 stream: None,
758 tail: Vec::new(),
759 }
760 }
761
762 fn is_empty(&self) -> bool {
763 self.head.is_empty() && self.stream.is_none() && self.tail.is_empty()
764 }
765
766 fn write(self, to: &mut impl Write) {
767 let _ = to.write_all(&self.head);
768 if let Some((path, excerpt)) = self.stream {
769 let copied = std::fs::File::open(&path).and_then(|mut f| std::io::copy(&mut f, to));
770 if let Err(e) = copied {
771 let _ = to.write_all(excerpt.as_bytes());
772 let _ = writeln!(to, "log not read: {e}");
773 }
774 }
775 let _ = to.write_all(&self.tail);
776 }
777}
778
779fn is_failure(outcome: Option<crate::check::Outcome>) -> bool {
783 use crate::check::Outcome;
784 match outcome {
785 None => true,
786 Some(Outcome::Failed) => true,
787 Some(
788 Outcome::Passed
789 | Outcome::Warned
790 | Outcome::Fixed
791 | Outcome::Unavailable
792 | Outcome::Inert,
793 ) => false,
794 }
795}
796
797fn heartbeat(settings: crate::config::Settings, weak: Weak<Stage>) {
798 loop {
799 std::thread::sleep(std::time::Duration::from_secs(1));
800 let Some(stage) = weak.upgrade() else { return };
801 if stage.stop.load(Ordering::Relaxed) {
802 return;
803 }
804 let mut announce: Vec<String> = Vec::new();
805 let due: Vec<(Row, bool)> = {
806 let mut slots = stage.slots.lock().unwrap_or_else(|p| p.into_inner());
807 let now = Instant::now();
808 let mut due = Vec::new();
809 for s in slots.iter_mut().filter(|s| s.running && !s.done) {
810 let elapsed = now.duration_since(s.started).as_secs();
811 if !s.announced && elapsed >= ANNOUNCE_SECS {
814 if let Some(c) = s.capture.as_ref().filter(|c| c.writing() && c.seen > 0) {
815 s.announced = true;
816 announce.push(format!("running {}, log {}\n", s.name, c.tmp.display()));
817 }
818 }
819 if elapsed >= s.next_beat {
820 let first = s.next_beat == HEARTBEAT_SECS;
821 s.next_beat += HEARTBEAT_SECS;
822 due.push((row_of(s, now, elapsed as f64), first));
823 }
824 }
825 due
826 };
827 if due.is_empty() && announce.is_empty() {
828 continue;
829 }
830 let text: String = announce.concat()
831 + &due
832 .iter()
833 .map(|(row, first)| beat_line(row, *first, budgets(&settings), stage.on_push))
834 .collect::<String>();
835 let _guard = stage.out.lock().unwrap_or_else(|p| p.into_inner());
836 let mut err = std::io::stderr().lock();
837 let _ = err.write_all(text.as_bytes());
838 let _ = err.flush();
839 }
840}
841
842fn beat_line(row: &Row, first: bool, budgets: Budgets, on_push: bool) -> String {
865 use crate::hooks::common::human_secs;
866 let mut line = format!(
869 " … {} still running: {}, last output {} ago",
870 row.name,
871 human_secs(row.elapsed as u64),
872 human_secs(row.quiet as u64)
873 );
874 if let Some((secs, n)) = row.queued {
875 line.push_str(&format!(
876 ", queued {} for a host slot (amont.hostSlots {n})",
877 human_secs(secs as u64)
878 ));
879 } else if let Some((kind, waited)) = row.wait {
880 line.push_str(&format!(
881 ", waiting for {} {}",
882 kind.describe(),
883 human_secs(waited as u64)
884 ));
885 match budgets.lock_wait {
886 0 => line.push_str(" (amont.lockWait 0: until the ceiling)"),
887 s => line.push_str(&format!(" (amont.lockWait {})", human_secs(s))),
888 }
889 } else {
890 match row.cpu {
891 RowCpu::Busy(m) => line.push_str(&format!(", busy {}", crate::hooks::common::cores(m))),
892 RowCpu::Idle => line.push_str(&format!(", CPU idle {}", human_secs(row.still as u64))),
893 RowCpu::Unmeasured if budgets.idle > 0 => line.push_str(&format!(
894 ", CPU unmeasured — extended budget {}",
895 human_secs(budgets.extended.max(budgets.idle))
896 )),
897 RowCpu::Unmeasured => line.push_str(", CPU unmeasured"),
898 RowCpu::None | RowCpu::NotSampled => {}
899 }
900 if let (Some((applied, load, factor)), false) = (row.load, row.cpu == RowCpu::Unmeasured) {
901 line.push_str(&format!(
902 ", budget {} (load avg {} on {} cores, {} — amont.idleLoadScale)",
903 human_secs(applied),
904 load.avg1_text(),
905 load.cores,
906 crate::load::factor_text(factor)
907 ));
908 }
909 }
910 if first {
911 let idle = match row.applied_idle(budgets) {
912 0 => "off".to_string(),
913 s => human_secs(s),
914 };
915 let ceiling = match budgets.ceiling {
916 0 => "off".to_string(),
917 s => human_secs(s),
918 };
919 match row.cpu {
920 _ if row.wait.is_some() => line.push_str(&format!(
923 " (killed after {} in the wait, or {ceiling} in total — amont.lockWait / \
924 amont.timeout)",
925 match budgets.lock_wait {
926 0 => ceiling.clone(),
927 s => human_secs(s),
928 }
929 )),
930 RowCpu::Unmeasured if budgets.idle > 0 => line.push_str(&format!(
933 " (killed after {} with no output while its CPU is unmeasured, or {ceiling} \
934 in total — amont.idleTimeout × amont.idleLoadScale / amont.timeout)",
935 human_secs(budgets.extended.max(budgets.idle))
936 )),
937 RowCpu::Busy(_) | RowCpu::Idle if budgets.idle > 0 => line.push_str(&format!(
938 " (killed after {idle} with no output and under 0.1 core of CPU, or \
939 {ceiling} in total — amont.idleTimeout / amont.timeout)"
940 )),
941 _ => line.push_str(&format!(
942 " (killed after {idle} of silence or {ceiling} in total — amont.idleTimeout / amont.timeout)"
943 )),
944 }
945 if row.cpu == RowCpu::NotSampled && budgets.idle > 0 {
946 line.push_str("; CPU not sampled here, silence alone counts");
947 }
948 if on_push {
949 line.push_str(concat!(
954 "\n git opened its connection to the remote before calling this",
955 "\n gate, and it stays idle until the gate finishes. A remote may",
956 "\n close it first — GitHub does — and the push then fails with",
957 "\n \"Connection reset by peer\", naming the network rather than the",
958 "\n wait. ssh keepalive does not prevent this.",
959 "\n Declaring this check at pre-commit moves it off the push path —",
960 "\n see \"Moving a gate entry earlier\" in the docs.",
961 ));
962 }
963 }
964 line.push('\n');
965 line
966}
967
968pub fn term_width() -> usize {
977 std::env::var("COLUMNS")
978 .ok()
979 .and_then(|c| c.parse::<usize>().ok())
980 .filter(|w| *w >= 20)
981 .unwrap_or(80)
982}
983
984pub struct FinishOnDrop<'a> {
988 stage: &'a Stage,
989 idx: usize,
990 outcome: std::cell::Cell<Option<crate::check::Outcome>>,
992 settings: &'a crate::config::Settings,
995}
996
997impl<'a> FinishOnDrop<'a> {
998 pub fn new(
999 settings: &'a crate::config::Settings,
1000 stage: &'a Stage,
1001 idx: usize,
1002 ) -> FinishOnDrop<'a> {
1003 FinishOnDrop {
1004 stage,
1005 idx,
1006 outcome: std::cell::Cell::new(None),
1007 settings,
1008 }
1009 }
1010
1011 pub fn outcome(&self, outcome: crate::check::Outcome) {
1013 self.outcome.set(Some(outcome));
1014 }
1015}
1016
1017impl Drop for FinishOnDrop<'_> {
1018 fn drop(&mut self) {
1019 self.stage
1020 .finish(self.settings, self.idx, self.outcome.get());
1021 }
1022}
1023
1024pub struct SinkGuard;
1026
1027impl Drop for SinkGuard {
1028 fn drop(&mut self) {
1029 SINK.with(|s| *s.borrow_mut() = None);
1030 }
1031}
1032
1033pub struct AttachGuard {
1036 stage: Arc<Stage>,
1037 idx: usize,
1038}
1039
1040impl Drop for AttachGuard {
1041 fn drop(&mut self) {
1042 let mut slots = self.stage.slots.lock().unwrap_or_else(|p| p.into_inner());
1043 if let Some(slot) = slots.get_mut(self.idx) {
1044 slot.activity = None;
1045 }
1046 }
1047}
1048
1049pub fn current_sink() -> Option<(Arc<Stage>, usize)> {
1052 SINK.with(|s| s.borrow().clone())
1053}
1054
1055pub fn say(line: &str) {
1061 let routed = SINK.with(|s| {
1062 s.borrow().as_ref().map(|(stage, idx)| {
1063 stage.append_line(*idx, line);
1064 })
1065 });
1066 if routed.is_none() {
1067 println!("{line}");
1068 }
1069}
1070
1071#[macro_export]
1076macro_rules! say {
1077 ($($arg:tt)*) => {
1078 $crate::live::say(&format!($($arg)*))
1079 };
1080}
1081
1082pub fn quiet(settings: &crate::config::Settings) -> bool {
1107 *settings.quiet.get_or_init(|| {
1108 decide(
1109 crate::config::enumerated_or(settings, "amont.quiet", QUIET_VALUES, "auto"),
1110 watching(),
1111 )
1112 })
1113}
1114
1115pub const QUIET_VALUES: &[&str] = &["never", "auto", "always"];
1116
1117fn decide(setting: &str, watching: bool) -> bool {
1119 match setting {
1120 "always" => true,
1121 "auto" => !watching,
1122 _ => false,
1125 }
1126}
1127
1128pub fn enabled(settings: &crate::config::Settings) -> bool {
1131 *settings
1132 .progress
1133 .get_or_init(|| crate::config::boolean_or(settings, "amont.progress", true))
1134}
1135
1136pub fn watching() -> bool {
1142 static WATCHING: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1143 *WATCHING.get_or_init(|| {
1144 if !std::io::stderr().is_terminal() {
1145 return false;
1146 }
1147 match std::env::var("TERM") {
1148 Ok(term) => term != "dumb",
1149 Err(_) => !cfg!(windows),
1150 }
1151 })
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156 use super::*;
1157
1158 fn test_settings() -> crate::config::Settings {
1159 crate::config::Settings::default()
1160 }
1161
1162 #[test]
1163 fn quiet_asks_who_is_reading() {
1164 assert!(!decide("never", true));
1165 assert!(!decide("never", false));
1166 assert!(always_and_auto_agree_at_a_terminal());
1167 assert!(decide("auto", false), "captured: nobody is watching");
1168 assert!(decide("always", true));
1169 assert!(decide("always", false));
1170 assert!(!decide("shhh", false));
1173 }
1174
1175 fn always_and_auto_agree_at_a_terminal() -> bool {
1176 !decide("auto", true) && decide("always", true)
1177 }
1178
1179 #[test]
1183 fn slots_do_not_share_a_buffer() {
1184 let stage = Stage::begin(&test_settings(), &["a", "b"]);
1185 std::thread::scope(|scope| {
1186 for idx in 0..2 {
1187 let stage = Arc::clone(&stage);
1188 scope.spawn(move || {
1189 let _guard = stage.enter(idx);
1190 for i in 0..50 {
1191 say(&format!("check-{idx} line-{i}"));
1192 std::thread::yield_now();
1193 }
1194 });
1195 }
1196 });
1197 let slots = stage.slots.lock().unwrap();
1198 for idx in 0..2 {
1199 let text = String::from_utf8(slots[idx].buf.clone()).unwrap();
1200 assert_eq!(text.lines().count(), 50);
1201 assert!(
1202 text.lines()
1203 .all(|l| l.starts_with(&format!("check-{idx} "))),
1204 "a foreign line landed in slot {idx}"
1205 );
1206 }
1207 }
1208
1209 #[test]
1211 fn no_sink_means_no_capture() {
1212 let stage = Stage::begin(&test_settings(), &["a"]);
1213 say("goes to stdout, not to a slot");
1214 let slots = stage.slots.lock().unwrap();
1215 assert!(slots[0].buf.is_empty());
1216 }
1217
1218 #[test]
1221 fn a_finished_slot_takes_no_more_writes() {
1222 let stage = Stage::begin(&test_settings(), &["a"]);
1223 stage.append_raw(0, b"before\n");
1224 stage.finish(&test_settings(), 0, None);
1225 stage.append_raw(0, b"after\n");
1226 let slots = stage.slots.lock().unwrap();
1227 assert!(slots[0].buf.is_empty(), "a write landed after finish");
1228 }
1229
1230 #[test]
1233 fn a_slot_name_is_sanitised_at_begin() {
1234 let stage = Stage::begin(&test_settings(), &["evil\u{1b}[2Jname\rhere"]);
1235 let slots = stage.slots.lock().unwrap();
1236 assert!(!slots[0].name.contains('\u{1b}'), "{:?}", slots[0].name);
1237 assert!(!slots[0].name.contains('\r'), "{:?}", slots[0].name);
1238 }
1239
1240 #[test]
1243 fn a_slot_name_drops_the_stage_prefix() {
1244 let stage = Stage::begin(
1245 &test_settings(),
1246 &["pre-commit-clippy", "pre-push-run-tests", "bare"],
1247 );
1248 let slots = stage.slots.lock().unwrap();
1249 assert_eq!(slots[0].name, "clippy");
1250 assert_eq!(slots[1].name, "run-tests");
1251 assert_eq!(slots[2].name, "bare");
1252 }
1253
1254 fn row(name: &str, elapsed: f64) -> Row {
1255 Row {
1256 name: name.into(),
1257 elapsed,
1258 quiet: 0.0,
1259 still: 0.0,
1260 cpu: RowCpu::None,
1261 wait: None,
1262 load: None,
1263 queued: None,
1264 }
1265 }
1266
1267 #[test]
1270 fn a_queued_check_says_it_waits_for_a_host_slot() {
1271 let mut r = row("pre-push-run-tests-js", 42.0);
1272 r.queued = Some((42.0, 2));
1273 let text = region(&[r.clone()], 80, B);
1274 assert!(text.contains("· queued 42s (slots 2/2)"), "{text:?}");
1275 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1276 r.elapsed = 60.0;
1277 r.quiet = 60.0;
1278 r.queued = Some((60.0, 2));
1279 let beat = beat_line(&r, false, B, false);
1280 assert_eq!(
1281 beat,
1282 " … pre-push-run-tests-js still running: 1m00s, last output 1m00s ago, \
1283 queued 1m00s for a host slot (amont.hostSlots 2)\n"
1284 );
1285 }
1286
1287 #[test]
1291 fn a_load_stretched_budget_is_shown_with_its_load() {
1292 let mut r = row("pre-push-run-tests-js", 240.0);
1293 r.quiet = 130.0;
1294 r.still = 130.0;
1295 r.cpu = RowCpu::Idle;
1296 r.load = Some((
1297 468,
1298 crate::load::Load {
1299 avg1_milli: 31_200,
1300 cores: 8,
1301 },
1302 3900,
1303 ));
1304 let text = region(&[r.clone()], 80, B);
1305 assert!(text.contains("· quiet 2m10s/7m48s (load ×3.9)"), "{text:?}");
1306 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1307 let beat = beat_line(&r, false, B, false);
1308 assert!(
1309 beat.ends_with(
1310 ", CPU idle 2m10s, budget 7m48s (load avg 31.2 on 8 cores, ×3.9 — amont.idleLoadScale)\n"
1311 ),
1312 "{beat:?}"
1313 );
1314 let first = flat(&beat_line(&r, true, B, false));
1315 assert!(
1316 first.contains("killed after 7m48s with no output and under 0.1 core of CPU"),
1317 "{first:?}"
1318 );
1319 }
1320
1321 const B: Budgets = Budgets {
1322 idle: 120,
1323 ceiling: 3600,
1324 lock_wait: 600,
1325 extended: 480,
1326 };
1327
1328 #[test]
1332 fn an_unmeasured_check_is_shown_against_the_extended_budget() {
1333 let mut r = row("pre-push-run-tests-js", 240.0);
1334 r.quiet = 130.0;
1335 r.still = 130.0;
1336 r.cpu = RowCpu::Unmeasured;
1337 let text = region(&[r.clone()], 80, B);
1338 assert!(
1339 text.contains("· quiet 2m10s/8m00s (CPU unmeasured)"),
1340 "{text:?}"
1341 );
1342 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1343 let beat = beat_line(&r, false, B, false);
1344 assert!(
1345 beat.ends_with(", CPU unmeasured — extended budget 8m00s\n"),
1346 "{beat:?}"
1347 );
1348 let first = flat(&beat_line(&r, true, B, false));
1349 assert!(
1350 first.contains(
1351 "killed after 8m00s with no output while its CPU is unmeasured, or 1h00m in total"
1352 ),
1353 "{first:?}"
1354 );
1355 assert!(first.contains("amont.idleLoadScale"), "{first:?}");
1356 }
1357
1358 #[test]
1362 fn a_declared_wait_is_shown_against_its_own_budget() {
1363 use crate::hooks::wait::{CargoLockWhat, WaitKind};
1364 let mut r = row("pre-push-run-tests-js", 240.0);
1365 r.quiet = 130.0;
1366 r.still = 0.0;
1367 r.cpu = RowCpu::Unmeasured;
1368 r.wait = Some((WaitKind::CargoLock(CargoLockWhat::BuildDirectory), 90.0));
1369 let text = region(&[r.clone()], 80, B);
1370 assert!(text.contains("· cargo lock 1m30s/10m00s"), "{text:?}");
1371 assert!(!text.contains("quiet"), "no silence countdown: {text:?}");
1372 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1373 let until = Budgets { lock_wait: 0, ..B };
1374 let text = region(&[r.clone()], 80, until);
1375 assert!(
1376 text.contains("· cargo lock 1m30s") && !text.contains("1m30s/"),
1377 "{text:?}"
1378 );
1379
1380 let beat = beat_line(&r, false, B, false);
1381 assert_eq!(
1382 beat,
1383 " … pre-push-run-tests-js still running: 4m00s, last output 2m10s ago, \
1384 waiting for the cargo lock on the build directory 1m30s (amont.lockWait 10m00s)\n"
1385 );
1386 let beat = beat_line(&r, false, until, false);
1387 assert!(
1388 beat.contains("(amont.lockWait 0: until the ceiling)"),
1389 "{beat:?}"
1390 );
1391 let first = flat(&beat_line(&r, true, B, false));
1394 assert!(
1395 first.contains("(killed after 10m00s in the wait, or 1h00m in total — amont.lockWait"),
1396 "{first:?}"
1397 );
1398 assert!(!first.contains("unmeasured"), "{first:?}");
1399 }
1400
1401 #[test]
1404 fn frames_advance_with_time() {
1405 let a = region(&[row("clippy", 0.0)], 80, B);
1406 let b = region(&[row("clippy", 0.1)], 80, B);
1407 let c = region(&[row("clippy", 1.0)], 80, B);
1408 assert_ne!(a.chars().next(), b.chars().next());
1409 assert_eq!(a.chars().next(), c.chars().next(), "10 frames per second");
1410 }
1411
1412 #[test]
1415 fn region_lines_align() {
1416 let text = region(&[row("a", 0.0), row("longer-name", 0.0)], 80, B);
1417 let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
1418 assert_eq!(widths[0], widths[1], "{text:?}");
1419 let text = region(&[row("a", 3.2), row("b", 492.0)], 80, B);
1420 let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
1421 assert_eq!(widths[0], widths[1], "{text:?}");
1422 assert!(text.contains("8m12s"), "{text:?}");
1423 }
1424
1425 #[test]
1427 fn region_caps_and_counts_the_rest() {
1428 let entries: Vec<Row> = (0..13).map(|i| row(&format!("check-{i}"), 0.0)).collect();
1429 let text = region(&entries, 80, B);
1430 assert_eq!(text.lines().count(), MAX_LINES + 1);
1431 assert!(text.ends_with("… and 1 more\n"), "{text:?}");
1432 }
1433
1434 #[test]
1437 fn region_respects_width() {
1438 let text = region(&[row("a-name-much-longer-than-the-terminal", 0.0)], 20, B);
1439 assert!(text.lines().all(|l| l.chars().count() <= 20), "{text:?}");
1440 }
1441
1442 #[test]
1444 fn an_empty_region_is_empty() {
1445 assert_eq!(region(&[], 80, B), "");
1446 }
1447
1448 #[test]
1452 fn a_quiet_check_shows_its_silence_against_the_budget() {
1453 let mut r = row("cargo-test", 300.0);
1454 r.quiet = 5.0;
1455 assert!(!region(&[r.clone()], 80, B).contains("quiet"));
1456 r.quiet = 45.0;
1457 let text = region(&[r.clone()], 80, B);
1458 assert!(text.contains("quiet 45s/2m00s"), "{text:?}");
1459 let off = Budgets { idle: 0, ..B };
1460 let text = region(&[r], 80, off);
1461 assert!(
1462 text.contains("quiet 45s") && !text.contains('/'),
1463 "{text:?}"
1464 );
1465 }
1466
1467 #[test]
1472 fn a_quiet_busy_check_shows_cores_and_an_idle_one_counts_down_the_still_time() {
1473 let mut r = row("vitest-workspace", 240.0);
1474 r.quiet = 130.0;
1475 r.still = 130.0;
1476 r.cpu = RowCpu::Busy(3900);
1477 let busy = region(&[r.clone()], 80, B);
1478 assert!(busy.contains("· quiet 2m10s · ~3.9 cores"), "{busy:?}");
1479 assert!(
1480 !busy.contains("/2m00s"),
1481 "no countdown while busy: {busy:?}"
1482 );
1483
1484 r.cpu = RowCpu::Idle;
1485 r.still = 40.0;
1486 let idle = region(&[r.clone()], 80, B);
1487 assert!(idle.contains("· quiet 2m10s · idle 40s/2m00s"), "{idle:?}");
1488
1489 r.cpu = RowCpu::Unmeasured;
1490 r.still = 130.0;
1491 let plain = region(&[r], 80, B);
1492 assert!(
1493 plain.contains("· quiet 2m10s/8m00s (CPU unmeasured)"),
1494 "{plain:?}"
1495 );
1496
1497 for text in [busy, idle, plain] {
1498 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1499 }
1500 }
1501
1502 #[test]
1505 fn a_heartbeat_appends_the_cpu_state_after_an_unchanged_prefix() {
1506 let mut r = row("vitest", 240.0);
1507 r.quiet = 130.0;
1508 r.still = 40.0;
1509 let prefix = " … vitest still running: 4m00s, last output 2m10s ago";
1510 for (cpu, suffix) in [
1511 (RowCpu::Busy(3900), ", busy ~3.9 cores\n"),
1512 (RowCpu::Idle, ", CPU idle 40s\n"),
1513 (
1514 RowCpu::Unmeasured,
1515 ", CPU unmeasured — extended budget 8m00s\n",
1516 ),
1517 (RowCpu::None, "\n"),
1518 (RowCpu::NotSampled, "\n"),
1519 ] {
1520 r.cpu = cpu;
1521 assert_eq!(beat_line(&r, false, B, false), format!("{prefix}{suffix}"));
1522 }
1523 }
1524
1525 #[test]
1527 fn the_first_beat_states_the_rule_in_force() {
1528 let mut r = row("vitest", 60.0);
1529 r.cpu = RowCpu::Idle;
1530 let sampled = beat_line(&r, true, B, false);
1531 assert!(
1532 flat(&sampled).contains(
1533 "killed after 2m00s with no output and under 0.1 core of CPU, or 1h00m in total"
1534 ),
1535 "{sampled:?}"
1536 );
1537 r.cpu = RowCpu::NotSampled;
1538 let not = beat_line(&r, true, B, false);
1539 assert!(
1540 not.contains("2m00s of silence or 1h00m in total"),
1541 "{not:?}"
1542 );
1543 assert!(
1544 not.contains("CPU not sampled here, silence alone counts"),
1545 "{not:?}"
1546 );
1547 }
1548
1549 #[test]
1552 fn the_ceiling_shows_only_when_it_is_near() {
1553 assert!(!region(&[row("cargo-test", 1000.0)], 80, B).contains("/1h00m"));
1554 let text = region(&[row("cargo-test", 3000.0)], 80, B);
1555 assert!(text.contains("50m00s/1h00m"), "{text:?}");
1556 let off = Budgets { ceiling: 0, ..B };
1557 assert!(!region(&[row("cargo-test", 3000.0)], 80, off).contains("/"));
1558 }
1559
1560 #[test]
1564 fn a_heartbeat_names_the_budgets_once() {
1565 let mut r = row("cargo-test", 60.0);
1566 r.quiet = 2.0;
1567 let first = beat_line(&r, true, B, false);
1568 assert!(
1569 first.contains("cargo-test still running: 1m00s"),
1570 "{first:?}"
1571 );
1572 assert!(first.contains("last output 2s ago"), "{first:?}");
1573 assert!(
1574 first.contains("2m00s of silence or 1h00m in total"),
1575 "{first:?}"
1576 );
1577 assert!(first.contains("amont.idleTimeout"), "{first:?}");
1578 let later = beat_line(&r, false, B, false);
1579 assert!(!later.contains("amont.idleTimeout"), "{later:?}");
1580 let off = beat_line(
1581 &r,
1582 true,
1583 Budgets {
1584 idle: 0,
1585 ceiling: 0,
1586 lock_wait: 0,
1587 extended: 0,
1588 },
1589 false,
1590 );
1591 assert!(off.contains("off of silence or off in total"), "{off:?}");
1592 }
1593
1594 fn flat(line: &str) -> String {
1601 line.split_whitespace().collect::<Vec<_>>().join(" ")
1602 }
1603
1604 #[test]
1611 fn a_long_push_gate_is_told_what_it_is_sitting_on() {
1612 let r = row("cargo-test", 60.0);
1613
1614 let pushing = flat(&beat_line(&r, true, B, true));
1615 assert!(
1616 pushing.contains("A remote may close it first"),
1617 "{pushing:?}"
1618 );
1619 assert!(pushing.contains("Connection reset by peer"), "{pushing:?}");
1620 assert!(
1621 pushing.contains("Moving a gate entry earlier"),
1622 "{pushing:?}"
1623 );
1624
1625 let later = flat(&beat_line(&r, false, B, true));
1627 assert!(!later.contains("close it first"), "{later:?}");
1628
1629 let committing = flat(&beat_line(&r, true, B, false));
1631 assert!(!committing.contains("close it first"), "{committing:?}");
1632 }
1633
1634 #[test]
1643 fn the_push_note_does_not_recommend_ssh_keepalive() {
1644 let r = row("cargo-test", 60.0);
1645 let pushing = flat(&beat_line(&r, true, B, true));
1646 assert!(
1647 !pushing.contains("ServerAlive"),
1648 "keepalive was already on when this failed; recommending it \
1649 would be useless advice: {pushing:?}"
1650 );
1651 assert!(
1652 pushing.contains("ssh keepalive does not prevent this"),
1653 "say so, rather than leaving the reader to try it: {pushing:?}"
1654 );
1655 }
1656}