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 exit = match (end.outcome, failed) {
674 (None, _) => None,
675 (Some(_), true) => Some(end.runner_exit.filter(|c| *c != 0).unwrap_or(1)),
676 (Some(_), false) => Some(0),
677 };
678 end.capture.end_entry(exit, stored.path());
679 let passed = end.outcome == Some(crate::check::Outcome::Passed);
680 if end.capture.seen == 0 {
681 if let Some(p) = stored.path() {
687 let _ = std::fs::remove_file(p);
688 }
689 let mut head = end.said;
690 if failed && end.runner_exit.is_some() {
691 head.extend_from_slice(b"(no output)\n");
692 if let Some(code) = end.runner_exit.filter(|c| *c != 0) {
693 head.extend_from_slice(
694 format!("{} failed, runner exit {code}\n", end.name).as_bytes(),
695 );
696 }
697 }
698 return QuietOut::plain(head);
699 }
700 if passed && !end.ok_line {
701 return QuietOut::plain(end.said);
704 }
705 let path = stored.path().map(|p| p.display().to_string());
706 if passed {
707 let mut out = format!("ok {} {:.1}s", end.name, end.secs);
708 if let (crate::capture::Stored::Saved(_), Some(p)) = (&stored, &path) {
709 out.push_str(&format!(" log={p}"));
710 }
711 out.push('\n');
712 if let Some(note) = stored.note() {
713 out.push_str(¬e);
714 out.push('\n');
715 }
716 return QuietOut::plain(out.into_bytes());
717 }
718 let excerpt = end.capture.acc.finish(path.as_deref().unwrap_or("-"));
719 let mut tail = String::new();
720 if failed {
721 if let Some(code) = end.runner_exit.filter(|c| *c != 0) {
722 tail.push_str(&format!("{} failed, runner exit {code}\n", end.name));
723 }
724 }
725 if let Some(p) = &path {
726 tail.push_str(&format!("full log: {p}\n"));
727 }
728 if let Some(note) = stored.note() {
729 tail.push_str(¬e);
730 tail.push('\n');
731 }
732 match (&stored, end.ci && failed) {
736 (crate::capture::Stored::Saved(p), true) => QuietOut {
737 head: end.said,
738 stream: Some((p.clone(), excerpt)),
739 tail: tail.into_bytes(),
740 },
741 _ => {
742 let mut head = end.said;
743 head.extend_from_slice(excerpt.as_bytes());
744 QuietOut {
745 head,
746 stream: None,
747 tail: tail.into_bytes(),
748 }
749 }
750 }
751}
752
753struct QuietOut {
756 head: Vec<u8>,
757 stream: Option<(std::path::PathBuf, String)>,
758 tail: Vec<u8>,
759}
760
761impl QuietOut {
762 fn plain(head: Vec<u8>) -> QuietOut {
763 QuietOut {
764 head,
765 stream: None,
766 tail: Vec::new(),
767 }
768 }
769
770 fn is_empty(&self) -> bool {
771 self.head.is_empty() && self.stream.is_none() && self.tail.is_empty()
772 }
773
774 fn write(self, to: &mut impl Write) {
775 let _ = to.write_all(&self.head);
776 if let Some((path, excerpt)) = self.stream {
777 let copied = std::fs::File::open(&path).and_then(|mut f| std::io::copy(&mut f, to));
778 if let Err(e) = copied {
779 let _ = to.write_all(excerpt.as_bytes());
780 let _ = writeln!(to, "log not read: {e}");
781 }
782 }
783 let _ = to.write_all(&self.tail);
784 }
785}
786
787fn is_failure(outcome: Option<crate::check::Outcome>) -> bool {
791 use crate::check::Outcome;
792 match outcome {
793 None => true,
794 Some(Outcome::Failed) => true,
795 Some(
796 Outcome::Passed
797 | Outcome::Warned
798 | Outcome::Fixed
799 | Outcome::Unavailable
800 | Outcome::Inert,
801 ) => false,
802 }
803}
804
805fn heartbeat(settings: crate::config::Settings, weak: Weak<Stage>) {
806 loop {
807 std::thread::sleep(std::time::Duration::from_secs(1));
808 let Some(stage) = weak.upgrade() else { return };
809 if stage.stop.load(Ordering::Relaxed) {
810 return;
811 }
812 let mut announce: Vec<String> = Vec::new();
813 let due: Vec<(Row, bool)> = {
814 let mut slots = stage.slots.lock().unwrap_or_else(|p| p.into_inner());
815 let now = Instant::now();
816 let mut due = Vec::new();
817 for s in slots.iter_mut().filter(|s| s.running && !s.done) {
818 let elapsed = now.duration_since(s.started).as_secs();
819 if !s.announced && elapsed >= ANNOUNCE_SECS {
822 if let Some(c) = s.capture.as_ref().filter(|c| c.writing() && c.seen > 0) {
823 s.announced = true;
824 announce.push(format!("running {}, log {}\n", s.name, c.tmp.display()));
825 }
826 }
827 if elapsed >= s.next_beat {
828 let first = s.next_beat == HEARTBEAT_SECS;
829 s.next_beat += HEARTBEAT_SECS;
830 due.push((row_of(s, now, elapsed as f64), first));
831 }
832 }
833 due
834 };
835 if due.is_empty() && announce.is_empty() {
836 continue;
837 }
838 let text: String = announce.concat()
839 + &due
840 .iter()
841 .map(|(row, first)| beat_line(row, *first, budgets(&settings), stage.on_push))
842 .collect::<String>();
843 let _guard = stage.out.lock().unwrap_or_else(|p| p.into_inner());
844 let mut err = std::io::stderr().lock();
845 let _ = err.write_all(text.as_bytes());
846 let _ = err.flush();
847 }
848}
849
850fn beat_line(row: &Row, first: bool, budgets: Budgets, on_push: bool) -> String {
873 use crate::hooks::common::human_secs;
874 let mut line = format!(
877 " … {} still running: {}, last output {} ago",
878 row.name,
879 human_secs(row.elapsed as u64),
880 human_secs(row.quiet as u64)
881 );
882 if let Some((secs, n)) = row.queued {
883 line.push_str(&format!(
884 ", queued {} for a host slot (amont.hostSlots {n})",
885 human_secs(secs as u64)
886 ));
887 } else if let Some((kind, waited)) = row.wait {
888 line.push_str(&format!(
889 ", waiting for {} {}",
890 kind.describe(),
891 human_secs(waited as u64)
892 ));
893 match budgets.lock_wait {
894 0 => line.push_str(" (amont.lockWait 0: until the ceiling)"),
895 s => line.push_str(&format!(" (amont.lockWait {})", human_secs(s))),
896 }
897 } else {
898 match row.cpu {
899 RowCpu::Busy(m) => line.push_str(&format!(", busy {}", crate::hooks::common::cores(m))),
900 RowCpu::Idle => line.push_str(&format!(", CPU idle {}", human_secs(row.still as u64))),
901 RowCpu::Unmeasured if budgets.idle > 0 => line.push_str(&format!(
902 ", CPU unmeasured — extended budget {}",
903 human_secs(budgets.extended.max(budgets.idle))
904 )),
905 RowCpu::Unmeasured => line.push_str(", CPU unmeasured"),
906 RowCpu::None | RowCpu::NotSampled => {}
907 }
908 if let (Some((applied, load, factor)), false) = (row.load, row.cpu == RowCpu::Unmeasured) {
909 line.push_str(&format!(
910 ", budget {} (load avg {} on {} cores, {} — amont.idleLoadScale)",
911 human_secs(applied),
912 load.avg1_text(),
913 load.cores,
914 crate::load::factor_text(factor)
915 ));
916 }
917 }
918 if first {
919 let idle = match row.applied_idle(budgets) {
920 0 => "off".to_string(),
921 s => human_secs(s),
922 };
923 let ceiling = match budgets.ceiling {
924 0 => "off".to_string(),
925 s => human_secs(s),
926 };
927 match row.cpu {
928 _ if row.wait.is_some() => line.push_str(&format!(
931 " (killed after {} in the wait, or {ceiling} in total — amont.lockWait / \
932 amont.timeout)",
933 match budgets.lock_wait {
934 0 => ceiling.clone(),
935 s => human_secs(s),
936 }
937 )),
938 RowCpu::Unmeasured if budgets.idle > 0 => line.push_str(&format!(
941 " (killed after {} with no output while its CPU is unmeasured, or {ceiling} \
942 in total — amont.idleTimeout × amont.idleLoadScale / amont.timeout)",
943 human_secs(budgets.extended.max(budgets.idle))
944 )),
945 RowCpu::Busy(_) | RowCpu::Idle if budgets.idle > 0 => line.push_str(&format!(
946 " (killed after {idle} with no output and under 0.1 core of CPU, or \
947 {ceiling} in total — amont.idleTimeout / amont.timeout)"
948 )),
949 _ => line.push_str(&format!(
950 " (killed after {idle} of silence or {ceiling} in total — amont.idleTimeout / amont.timeout)"
951 )),
952 }
953 if row.cpu == RowCpu::NotSampled && budgets.idle > 0 {
954 line.push_str("; CPU not sampled here, silence alone counts");
955 }
956 if on_push {
957 line.push_str(concat!(
962 "\n git opened its connection to the remote before calling this",
963 "\n gate, and it stays idle until the gate finishes. A remote may",
964 "\n close it first — GitHub does — and the push then fails with",
965 "\n \"Connection reset by peer\", naming the network rather than the",
966 "\n wait. ssh keepalive does not prevent this.",
967 "\n Declaring this check at pre-commit moves it off the push path —",
968 "\n see \"Moving a gate entry earlier\" in the docs.",
969 ));
970 }
971 }
972 line.push('\n');
973 line
974}
975
976pub fn term_width() -> usize {
985 std::env::var("COLUMNS")
986 .ok()
987 .and_then(|c| c.parse::<usize>().ok())
988 .filter(|w| *w >= 20)
989 .unwrap_or(80)
990}
991
992pub struct FinishOnDrop<'a> {
996 stage: &'a Stage,
997 idx: usize,
998 outcome: std::cell::Cell<Option<crate::check::Outcome>>,
1000 settings: &'a crate::config::Settings,
1003}
1004
1005impl<'a> FinishOnDrop<'a> {
1006 pub fn new(
1007 settings: &'a crate::config::Settings,
1008 stage: &'a Stage,
1009 idx: usize,
1010 ) -> FinishOnDrop<'a> {
1011 FinishOnDrop {
1012 stage,
1013 idx,
1014 outcome: std::cell::Cell::new(None),
1015 settings,
1016 }
1017 }
1018
1019 pub fn outcome(&self, outcome: crate::check::Outcome) {
1021 self.outcome.set(Some(outcome));
1022 }
1023}
1024
1025impl Drop for FinishOnDrop<'_> {
1026 fn drop(&mut self) {
1027 self.stage
1028 .finish(self.settings, self.idx, self.outcome.get());
1029 }
1030}
1031
1032pub struct SinkGuard;
1034
1035impl Drop for SinkGuard {
1036 fn drop(&mut self) {
1037 SINK.with(|s| *s.borrow_mut() = None);
1038 }
1039}
1040
1041pub struct AttachGuard {
1044 stage: Arc<Stage>,
1045 idx: usize,
1046}
1047
1048impl Drop for AttachGuard {
1049 fn drop(&mut self) {
1050 let mut slots = self.stage.slots.lock().unwrap_or_else(|p| p.into_inner());
1051 if let Some(slot) = slots.get_mut(self.idx) {
1052 slot.activity = None;
1053 }
1054 }
1055}
1056
1057pub fn current_sink() -> Option<(Arc<Stage>, usize)> {
1060 SINK.with(|s| s.borrow().clone())
1061}
1062
1063pub fn say(line: &str) {
1069 let routed = SINK.with(|s| {
1070 s.borrow().as_ref().map(|(stage, idx)| {
1071 stage.append_line(*idx, line);
1072 })
1073 });
1074 if routed.is_none() {
1075 println!("{line}");
1076 }
1077}
1078
1079#[macro_export]
1084macro_rules! say {
1085 ($($arg:tt)*) => {
1086 $crate::live::say(&format!($($arg)*))
1087 };
1088}
1089
1090pub fn quiet(settings: &crate::config::Settings) -> bool {
1115 *settings.quiet.get_or_init(|| {
1116 decide(
1117 crate::config::enumerated_or(settings, "amont.quiet", QUIET_VALUES, "auto"),
1118 watching(),
1119 )
1120 })
1121}
1122
1123pub const QUIET_VALUES: &[&str] = &["never", "auto", "always"];
1124
1125fn decide(setting: &str, watching: bool) -> bool {
1127 match setting {
1128 "always" => true,
1129 "auto" => !watching,
1130 _ => false,
1133 }
1134}
1135
1136pub fn enabled(settings: &crate::config::Settings) -> bool {
1139 *settings
1140 .progress
1141 .get_or_init(|| crate::config::boolean_or(settings, "amont.progress", true))
1142}
1143
1144pub fn watching() -> bool {
1150 static WATCHING: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1151 *WATCHING.get_or_init(|| {
1152 if !std::io::stderr().is_terminal() {
1153 return false;
1154 }
1155 match std::env::var("TERM") {
1156 Ok(term) => term != "dumb",
1157 Err(_) => !cfg!(windows),
1158 }
1159 })
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164 use super::*;
1165
1166 fn test_settings() -> crate::config::Settings {
1167 crate::config::Settings::default()
1168 }
1169
1170 #[test]
1171 fn quiet_asks_who_is_reading() {
1172 assert!(!decide("never", true));
1173 assert!(!decide("never", false));
1174 assert!(always_and_auto_agree_at_a_terminal());
1175 assert!(decide("auto", false), "captured: nobody is watching");
1176 assert!(decide("always", true));
1177 assert!(decide("always", false));
1178 assert!(!decide("shhh", false));
1181 }
1182
1183 fn always_and_auto_agree_at_a_terminal() -> bool {
1184 !decide("auto", true) && decide("always", true)
1185 }
1186
1187 #[test]
1191 fn slots_do_not_share_a_buffer() {
1192 let stage = Stage::begin(&test_settings(), &["a", "b"]);
1193 std::thread::scope(|scope| {
1194 for idx in 0..2 {
1195 let stage = Arc::clone(&stage);
1196 scope.spawn(move || {
1197 let _guard = stage.enter(idx);
1198 for i in 0..50 {
1199 say(&format!("check-{idx} line-{i}"));
1200 std::thread::yield_now();
1201 }
1202 });
1203 }
1204 });
1205 let slots = stage.slots.lock().unwrap();
1206 for idx in 0..2 {
1207 let text = String::from_utf8(slots[idx].buf.clone()).unwrap();
1208 assert_eq!(text.lines().count(), 50);
1209 assert!(
1210 text.lines()
1211 .all(|l| l.starts_with(&format!("check-{idx} "))),
1212 "a foreign line landed in slot {idx}"
1213 );
1214 }
1215 }
1216
1217 #[test]
1219 fn no_sink_means_no_capture() {
1220 let stage = Stage::begin(&test_settings(), &["a"]);
1221 say("goes to stdout, not to a slot");
1222 let slots = stage.slots.lock().unwrap();
1223 assert!(slots[0].buf.is_empty());
1224 }
1225
1226 #[test]
1229 fn a_finished_slot_takes_no_more_writes() {
1230 let stage = Stage::begin(&test_settings(), &["a"]);
1231 stage.append_raw(0, b"before\n");
1232 stage.finish(&test_settings(), 0, None);
1233 stage.append_raw(0, b"after\n");
1234 let slots = stage.slots.lock().unwrap();
1235 assert!(slots[0].buf.is_empty(), "a write landed after finish");
1236 }
1237
1238 #[test]
1241 fn a_slot_name_is_sanitised_at_begin() {
1242 let stage = Stage::begin(&test_settings(), &["evil\u{1b}[2Jname\rhere"]);
1243 let slots = stage.slots.lock().unwrap();
1244 assert!(!slots[0].name.contains('\u{1b}'), "{:?}", slots[0].name);
1245 assert!(!slots[0].name.contains('\r'), "{:?}", slots[0].name);
1246 }
1247
1248 #[test]
1251 fn a_slot_name_drops_the_stage_prefix() {
1252 let stage = Stage::begin(
1253 &test_settings(),
1254 &["pre-commit-clippy", "pre-push-run-tests", "bare"],
1255 );
1256 let slots = stage.slots.lock().unwrap();
1257 assert_eq!(slots[0].name, "clippy");
1258 assert_eq!(slots[1].name, "run-tests");
1259 assert_eq!(slots[2].name, "bare");
1260 }
1261
1262 fn row(name: &str, elapsed: f64) -> Row {
1263 Row {
1264 name: name.into(),
1265 elapsed,
1266 quiet: 0.0,
1267 still: 0.0,
1268 cpu: RowCpu::None,
1269 wait: None,
1270 load: None,
1271 queued: None,
1272 }
1273 }
1274
1275 #[test]
1278 fn a_queued_check_says_it_waits_for_a_host_slot() {
1279 let mut r = row("pre-push-run-tests-js", 42.0);
1280 r.queued = Some((42.0, 2));
1281 let text = region(&[r.clone()], 80, B);
1282 assert!(text.contains("· queued 42s (slots 2/2)"), "{text:?}");
1283 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1284 r.elapsed = 60.0;
1285 r.quiet = 60.0;
1286 r.queued = Some((60.0, 2));
1287 let beat = beat_line(&r, false, B, false);
1288 assert_eq!(
1289 beat,
1290 " … pre-push-run-tests-js still running: 1m00s, last output 1m00s ago, \
1291 queued 1m00s for a host slot (amont.hostSlots 2)\n"
1292 );
1293 }
1294
1295 #[test]
1299 fn a_load_stretched_budget_is_shown_with_its_load() {
1300 let mut r = row("pre-push-run-tests-js", 240.0);
1301 r.quiet = 130.0;
1302 r.still = 130.0;
1303 r.cpu = RowCpu::Idle;
1304 r.load = Some((
1305 468,
1306 crate::load::Load {
1307 avg1_milli: 31_200,
1308 cores: 8,
1309 },
1310 3900,
1311 ));
1312 let text = region(&[r.clone()], 80, B);
1313 assert!(text.contains("· quiet 2m10s/7m48s (load ×3.9)"), "{text:?}");
1314 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1315 let beat = beat_line(&r, false, B, false);
1316 assert!(
1317 beat.ends_with(
1318 ", CPU idle 2m10s, budget 7m48s (load avg 31.2 on 8 cores, ×3.9 — amont.idleLoadScale)\n"
1319 ),
1320 "{beat:?}"
1321 );
1322 let first = flat(&beat_line(&r, true, B, false));
1323 assert!(
1324 first.contains("killed after 7m48s with no output and under 0.1 core of CPU"),
1325 "{first:?}"
1326 );
1327 }
1328
1329 const B: Budgets = Budgets {
1330 idle: 120,
1331 ceiling: 3600,
1332 lock_wait: 600,
1333 extended: 480,
1334 };
1335
1336 #[test]
1340 fn an_unmeasured_check_is_shown_against_the_extended_budget() {
1341 let mut r = row("pre-push-run-tests-js", 240.0);
1342 r.quiet = 130.0;
1343 r.still = 130.0;
1344 r.cpu = RowCpu::Unmeasured;
1345 let text = region(&[r.clone()], 80, B);
1346 assert!(
1347 text.contains("· quiet 2m10s/8m00s (CPU unmeasured)"),
1348 "{text:?}"
1349 );
1350 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1351 let beat = beat_line(&r, false, B, false);
1352 assert!(
1353 beat.ends_with(", CPU unmeasured — extended budget 8m00s\n"),
1354 "{beat:?}"
1355 );
1356 let first = flat(&beat_line(&r, true, B, false));
1357 assert!(
1358 first.contains(
1359 "killed after 8m00s with no output while its CPU is unmeasured, or 1h00m in total"
1360 ),
1361 "{first:?}"
1362 );
1363 assert!(first.contains("amont.idleLoadScale"), "{first:?}");
1364 }
1365
1366 #[test]
1370 fn a_declared_wait_is_shown_against_its_own_budget() {
1371 use crate::hooks::wait::{CargoLockWhat, WaitKind};
1372 let mut r = row("pre-push-run-tests-js", 240.0);
1373 r.quiet = 130.0;
1374 r.still = 0.0;
1375 r.cpu = RowCpu::Unmeasured;
1376 r.wait = Some((WaitKind::CargoLock(CargoLockWhat::BuildDirectory), 90.0));
1377 let text = region(&[r.clone()], 80, B);
1378 assert!(text.contains("· cargo lock 1m30s/10m00s"), "{text:?}");
1379 assert!(!text.contains("quiet"), "no silence countdown: {text:?}");
1380 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1381 let until = Budgets { lock_wait: 0, ..B };
1382 let text = region(&[r.clone()], 80, until);
1383 assert!(
1384 text.contains("· cargo lock 1m30s") && !text.contains("1m30s/"),
1385 "{text:?}"
1386 );
1387
1388 let beat = beat_line(&r, false, B, false);
1389 assert_eq!(
1390 beat,
1391 " … pre-push-run-tests-js still running: 4m00s, last output 2m10s ago, \
1392 waiting for the cargo lock on the build directory 1m30s (amont.lockWait 10m00s)\n"
1393 );
1394 let beat = beat_line(&r, false, until, false);
1395 assert!(
1396 beat.contains("(amont.lockWait 0: until the ceiling)"),
1397 "{beat:?}"
1398 );
1399 let first = flat(&beat_line(&r, true, B, false));
1402 assert!(
1403 first.contains("(killed after 10m00s in the wait, or 1h00m in total — amont.lockWait"),
1404 "{first:?}"
1405 );
1406 assert!(!first.contains("unmeasured"), "{first:?}");
1407 }
1408
1409 #[test]
1412 fn frames_advance_with_time() {
1413 let a = region(&[row("clippy", 0.0)], 80, B);
1414 let b = region(&[row("clippy", 0.1)], 80, B);
1415 let c = region(&[row("clippy", 1.0)], 80, B);
1416 assert_ne!(a.chars().next(), b.chars().next());
1417 assert_eq!(a.chars().next(), c.chars().next(), "10 frames per second");
1418 }
1419
1420 #[test]
1423 fn region_lines_align() {
1424 let text = region(&[row("a", 0.0), row("longer-name", 0.0)], 80, B);
1425 let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
1426 assert_eq!(widths[0], widths[1], "{text:?}");
1427 let text = region(&[row("a", 3.2), row("b", 492.0)], 80, B);
1428 let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
1429 assert_eq!(widths[0], widths[1], "{text:?}");
1430 assert!(text.contains("8m12s"), "{text:?}");
1431 }
1432
1433 #[test]
1435 fn region_caps_and_counts_the_rest() {
1436 let entries: Vec<Row> = (0..13).map(|i| row(&format!("check-{i}"), 0.0)).collect();
1437 let text = region(&entries, 80, B);
1438 assert_eq!(text.lines().count(), MAX_LINES + 1);
1439 assert!(text.ends_with("… and 1 more\n"), "{text:?}");
1440 }
1441
1442 #[test]
1445 fn region_respects_width() {
1446 let text = region(&[row("a-name-much-longer-than-the-terminal", 0.0)], 20, B);
1447 assert!(text.lines().all(|l| l.chars().count() <= 20), "{text:?}");
1448 }
1449
1450 #[test]
1452 fn an_empty_region_is_empty() {
1453 assert_eq!(region(&[], 80, B), "");
1454 }
1455
1456 #[test]
1460 fn a_quiet_check_shows_its_silence_against_the_budget() {
1461 let mut r = row("cargo-test", 300.0);
1462 r.quiet = 5.0;
1463 assert!(!region(&[r.clone()], 80, B).contains("quiet"));
1464 r.quiet = 45.0;
1465 let text = region(&[r.clone()], 80, B);
1466 assert!(text.contains("quiet 45s/2m00s"), "{text:?}");
1467 let off = Budgets { idle: 0, ..B };
1468 let text = region(&[r], 80, off);
1469 assert!(
1470 text.contains("quiet 45s") && !text.contains('/'),
1471 "{text:?}"
1472 );
1473 }
1474
1475 #[test]
1480 fn a_quiet_busy_check_shows_cores_and_an_idle_one_counts_down_the_still_time() {
1481 let mut r = row("vitest-workspace", 240.0);
1482 r.quiet = 130.0;
1483 r.still = 130.0;
1484 r.cpu = RowCpu::Busy(3900);
1485 let busy = region(&[r.clone()], 80, B);
1486 assert!(busy.contains("· quiet 2m10s · ~3.9 cores"), "{busy:?}");
1487 assert!(
1488 !busy.contains("/2m00s"),
1489 "no countdown while busy: {busy:?}"
1490 );
1491
1492 r.cpu = RowCpu::Idle;
1493 r.still = 40.0;
1494 let idle = region(&[r.clone()], 80, B);
1495 assert!(idle.contains("· quiet 2m10s · idle 40s/2m00s"), "{idle:?}");
1496
1497 r.cpu = RowCpu::Unmeasured;
1498 r.still = 130.0;
1499 let plain = region(&[r], 80, B);
1500 assert!(
1501 plain.contains("· quiet 2m10s/8m00s (CPU unmeasured)"),
1502 "{plain:?}"
1503 );
1504
1505 for text in [busy, idle, plain] {
1506 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1507 }
1508 }
1509
1510 #[test]
1513 fn a_heartbeat_appends_the_cpu_state_after_an_unchanged_prefix() {
1514 let mut r = row("vitest", 240.0);
1515 r.quiet = 130.0;
1516 r.still = 40.0;
1517 let prefix = " … vitest still running: 4m00s, last output 2m10s ago";
1518 for (cpu, suffix) in [
1519 (RowCpu::Busy(3900), ", busy ~3.9 cores\n"),
1520 (RowCpu::Idle, ", CPU idle 40s\n"),
1521 (
1522 RowCpu::Unmeasured,
1523 ", CPU unmeasured — extended budget 8m00s\n",
1524 ),
1525 (RowCpu::None, "\n"),
1526 (RowCpu::NotSampled, "\n"),
1527 ] {
1528 r.cpu = cpu;
1529 assert_eq!(beat_line(&r, false, B, false), format!("{prefix}{suffix}"));
1530 }
1531 }
1532
1533 #[test]
1535 fn the_first_beat_states_the_rule_in_force() {
1536 let mut r = row("vitest", 60.0);
1537 r.cpu = RowCpu::Idle;
1538 let sampled = beat_line(&r, true, B, false);
1539 assert!(
1540 flat(&sampled).contains(
1541 "killed after 2m00s with no output and under 0.1 core of CPU, or 1h00m in total"
1542 ),
1543 "{sampled:?}"
1544 );
1545 r.cpu = RowCpu::NotSampled;
1546 let not = beat_line(&r, true, B, false);
1547 assert!(
1548 not.contains("2m00s of silence or 1h00m in total"),
1549 "{not:?}"
1550 );
1551 assert!(
1552 not.contains("CPU not sampled here, silence alone counts"),
1553 "{not:?}"
1554 );
1555 }
1556
1557 #[test]
1560 fn the_ceiling_shows_only_when_it_is_near() {
1561 assert!(!region(&[row("cargo-test", 1000.0)], 80, B).contains("/1h00m"));
1562 let text = region(&[row("cargo-test", 3000.0)], 80, B);
1563 assert!(text.contains("50m00s/1h00m"), "{text:?}");
1564 let off = Budgets { ceiling: 0, ..B };
1565 assert!(!region(&[row("cargo-test", 3000.0)], 80, off).contains("/"));
1566 }
1567
1568 #[test]
1572 fn a_heartbeat_names_the_budgets_once() {
1573 let mut r = row("cargo-test", 60.0);
1574 r.quiet = 2.0;
1575 let first = beat_line(&r, true, B, false);
1576 assert!(
1577 first.contains("cargo-test still running: 1m00s"),
1578 "{first:?}"
1579 );
1580 assert!(first.contains("last output 2s ago"), "{first:?}");
1581 assert!(
1582 first.contains("2m00s of silence or 1h00m in total"),
1583 "{first:?}"
1584 );
1585 assert!(first.contains("amont.idleTimeout"), "{first:?}");
1586 let later = beat_line(&r, false, B, false);
1587 assert!(!later.contains("amont.idleTimeout"), "{later:?}");
1588 let off = beat_line(
1589 &r,
1590 true,
1591 Budgets {
1592 idle: 0,
1593 ceiling: 0,
1594 lock_wait: 0,
1595 extended: 0,
1596 },
1597 false,
1598 );
1599 assert!(off.contains("off of silence or off in total"), "{off:?}");
1600 }
1601
1602 fn flat(line: &str) -> String {
1609 line.split_whitespace().collect::<Vec<_>>().join(" ")
1610 }
1611
1612 #[test]
1619 fn a_long_push_gate_is_told_what_it_is_sitting_on() {
1620 let r = row("cargo-test", 60.0);
1621
1622 let pushing = flat(&beat_line(&r, true, B, true));
1623 assert!(
1624 pushing.contains("A remote may close it first"),
1625 "{pushing:?}"
1626 );
1627 assert!(pushing.contains("Connection reset by peer"), "{pushing:?}");
1628 assert!(
1629 pushing.contains("Moving a gate entry earlier"),
1630 "{pushing:?}"
1631 );
1632
1633 let later = flat(&beat_line(&r, false, B, true));
1635 assert!(!later.contains("close it first"), "{later:?}");
1636
1637 let committing = flat(&beat_line(&r, true, B, false));
1639 assert!(!committing.contains("close it first"), "{committing:?}");
1640 }
1641
1642 #[test]
1651 fn the_push_note_does_not_recommend_ssh_keepalive() {
1652 let r = row("cargo-test", 60.0);
1653 let pushing = flat(&beat_line(&r, true, B, true));
1654 assert!(
1655 !pushing.contains("ServerAlive"),
1656 "keepalive was already on when this failed; recommending it \
1657 would be useless advice: {pushing:?}"
1658 );
1659 assert!(
1660 pushing.contains("ssh keepalive does not prevent this"),
1661 "say so, rather than leaving the reader to try it: {pushing:?}"
1662 );
1663 }
1664}