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(crate::clock::scaled(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 ran = now.duration_since(s.started);
819 let elapsed = ran.as_secs();
820 if !s.announced
823 && ran >= crate::clock::scaled(std::time::Duration::from_secs(ANNOUNCE_SECS))
824 {
825 if let Some(c) = s.capture.as_ref().filter(|c| c.writing() && c.seen > 0) {
826 s.announced = true;
827 announce.push(format!("running {}, log {}\n", s.name, c.tmp.display()));
828 }
829 }
830 if elapsed >= s.next_beat {
831 let first = s.next_beat == HEARTBEAT_SECS;
832 s.next_beat += HEARTBEAT_SECS;
833 due.push((row_of(s, now, elapsed as f64), first));
834 }
835 }
836 due
837 };
838 if due.is_empty() && announce.is_empty() {
839 continue;
840 }
841 let text: String = announce.concat()
842 + &due
843 .iter()
844 .map(|(row, first)| beat_line(row, *first, budgets(&settings), stage.on_push))
845 .collect::<String>();
846 let _guard = stage.out.lock().unwrap_or_else(|p| p.into_inner());
847 let mut err = std::io::stderr().lock();
848 let _ = err.write_all(text.as_bytes());
849 let _ = err.flush();
850 }
851}
852
853fn beat_line(row: &Row, first: bool, budgets: Budgets, on_push: bool) -> String {
876 use crate::hooks::common::human_secs;
877 let mut line = format!(
880 " … {} still running: {}, last output {} ago",
881 row.name,
882 human_secs(row.elapsed as u64),
883 human_secs(row.quiet as u64)
884 );
885 if let Some((secs, n)) = row.queued {
886 line.push_str(&format!(
887 ", queued {} for a host slot (amont.hostSlots {n})",
888 human_secs(secs as u64)
889 ));
890 } else if let Some((kind, waited)) = row.wait {
891 line.push_str(&format!(
892 ", waiting for {} {}",
893 kind.describe(),
894 human_secs(waited as u64)
895 ));
896 match budgets.lock_wait {
897 0 => line.push_str(" (amont.lockWait 0: until the ceiling)"),
898 s => line.push_str(&format!(" (amont.lockWait {})", human_secs(s))),
899 }
900 } else {
901 match row.cpu {
902 RowCpu::Busy(m) => line.push_str(&format!(", busy {}", crate::hooks::common::cores(m))),
903 RowCpu::Idle => line.push_str(&format!(", CPU idle {}", human_secs(row.still as u64))),
904 RowCpu::Unmeasured if budgets.idle > 0 => line.push_str(&format!(
905 ", CPU unmeasured — extended budget {}",
906 human_secs(budgets.extended.max(budgets.idle))
907 )),
908 RowCpu::Unmeasured => line.push_str(", CPU unmeasured"),
909 RowCpu::None | RowCpu::NotSampled => {}
910 }
911 if let (Some((applied, load, factor)), false) = (row.load, row.cpu == RowCpu::Unmeasured) {
912 line.push_str(&format!(
913 ", budget {} (load avg {} on {} cores, {} — amont.idleLoadScale)",
914 human_secs(applied),
915 load.avg1_text(),
916 load.cores,
917 crate::load::factor_text(factor)
918 ));
919 }
920 }
921 if first {
922 let idle = match row.applied_idle(budgets) {
923 0 => "off".to_string(),
924 s => human_secs(s),
925 };
926 let ceiling = match budgets.ceiling {
927 0 => "off".to_string(),
928 s => human_secs(s),
929 };
930 match row.cpu {
931 _ if row.wait.is_some() => line.push_str(&format!(
934 " (killed after {} in the wait, or {ceiling} in total — amont.lockWait / \
935 amont.timeout)",
936 match budgets.lock_wait {
937 0 => ceiling.clone(),
938 s => human_secs(s),
939 }
940 )),
941 RowCpu::Unmeasured if budgets.idle > 0 => line.push_str(&format!(
944 " (killed after {} with no output while its CPU is unmeasured, or {ceiling} \
945 in total — amont.idleTimeout × amont.idleLoadScale / amont.timeout)",
946 human_secs(budgets.extended.max(budgets.idle))
947 )),
948 RowCpu::Busy(_) | RowCpu::Idle if budgets.idle > 0 => line.push_str(&format!(
949 " (killed after {idle} with no output and under 0.1 core of CPU, or \
950 {ceiling} in total — amont.idleTimeout / amont.timeout)"
951 )),
952 _ => line.push_str(&format!(
953 " (killed after {idle} of silence or {ceiling} in total — amont.idleTimeout / amont.timeout)"
954 )),
955 }
956 if row.cpu == RowCpu::NotSampled && budgets.idle > 0 {
957 line.push_str("; CPU not sampled here, silence alone counts");
958 }
959 if on_push {
960 line.push_str(concat!(
965 "\n git opened its connection to the remote before calling this",
966 "\n gate, and it stays idle until the gate finishes. A remote may",
967 "\n close it first — GitHub does — and the push then fails with",
968 "\n \"Connection reset by peer\", naming the network rather than the",
969 "\n wait. ssh keepalive does not prevent this.",
970 "\n Declaring this check at pre-commit moves it off the push path —",
971 "\n see \"Moving a gate entry earlier\" in the docs.",
972 ));
973 }
974 }
975 line.push('\n');
976 line
977}
978
979pub fn term_width() -> usize {
988 std::env::var("COLUMNS")
989 .ok()
990 .and_then(|c| c.parse::<usize>().ok())
991 .filter(|w| *w >= 20)
992 .unwrap_or(80)
993}
994
995pub struct FinishOnDrop<'a> {
999 stage: &'a Stage,
1000 idx: usize,
1001 outcome: std::cell::Cell<Option<crate::check::Outcome>>,
1003 settings: &'a crate::config::Settings,
1006}
1007
1008impl<'a> FinishOnDrop<'a> {
1009 pub fn new(
1010 settings: &'a crate::config::Settings,
1011 stage: &'a Stage,
1012 idx: usize,
1013 ) -> FinishOnDrop<'a> {
1014 FinishOnDrop {
1015 stage,
1016 idx,
1017 outcome: std::cell::Cell::new(None),
1018 settings,
1019 }
1020 }
1021
1022 pub fn outcome(&self, outcome: crate::check::Outcome) {
1024 self.outcome.set(Some(outcome));
1025 }
1026}
1027
1028impl Drop for FinishOnDrop<'_> {
1029 fn drop(&mut self) {
1030 self.stage
1031 .finish(self.settings, self.idx, self.outcome.get());
1032 }
1033}
1034
1035pub struct SinkGuard;
1037
1038impl Drop for SinkGuard {
1039 fn drop(&mut self) {
1040 SINK.with(|s| *s.borrow_mut() = None);
1041 }
1042}
1043
1044pub struct AttachGuard {
1047 stage: Arc<Stage>,
1048 idx: usize,
1049}
1050
1051impl Drop for AttachGuard {
1052 fn drop(&mut self) {
1053 let mut slots = self.stage.slots.lock().unwrap_or_else(|p| p.into_inner());
1054 if let Some(slot) = slots.get_mut(self.idx) {
1055 slot.activity = None;
1056 }
1057 }
1058}
1059
1060pub fn current_sink() -> Option<(Arc<Stage>, usize)> {
1063 SINK.with(|s| s.borrow().clone())
1064}
1065
1066pub fn say(line: &str) {
1072 let routed = SINK.with(|s| {
1073 s.borrow().as_ref().map(|(stage, idx)| {
1074 stage.append_line(*idx, line);
1075 })
1076 });
1077 if routed.is_none() {
1078 println!("{line}");
1079 }
1080}
1081
1082#[macro_export]
1087macro_rules! say {
1088 ($($arg:tt)*) => {
1089 $crate::live::say(&format!($($arg)*))
1090 };
1091}
1092
1093pub fn quiet(settings: &crate::config::Settings) -> bool {
1118 *settings.quiet.get_or_init(|| {
1119 decide(
1120 crate::config::enumerated_or(settings, "amont.quiet", QUIET_VALUES, "auto"),
1121 watching(),
1122 )
1123 })
1124}
1125
1126pub const QUIET_VALUES: &[&str] = &["never", "auto", "always"];
1127
1128fn decide(setting: &str, watching: bool) -> bool {
1130 match setting {
1131 "always" => true,
1132 "auto" => !watching,
1133 _ => false,
1136 }
1137}
1138
1139pub fn enabled(settings: &crate::config::Settings) -> bool {
1142 *settings
1143 .progress
1144 .get_or_init(|| crate::config::boolean_or(settings, "amont.progress", true))
1145}
1146
1147pub fn watching() -> bool {
1153 static WATCHING: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1154 *WATCHING.get_or_init(|| {
1155 if !std::io::stderr().is_terminal() {
1156 return false;
1157 }
1158 match std::env::var("TERM") {
1159 Ok(term) => term != "dumb",
1160 Err(_) => !cfg!(windows),
1161 }
1162 })
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167 use super::*;
1168
1169 fn test_settings() -> crate::config::Settings {
1170 crate::config::Settings::default()
1171 }
1172
1173 #[test]
1174 fn quiet_asks_who_is_reading() {
1175 assert!(!decide("never", true));
1176 assert!(!decide("never", false));
1177 assert!(always_and_auto_agree_at_a_terminal());
1178 assert!(decide("auto", false), "captured: nobody is watching");
1179 assert!(decide("always", true));
1180 assert!(decide("always", false));
1181 assert!(!decide("shhh", false));
1184 }
1185
1186 fn always_and_auto_agree_at_a_terminal() -> bool {
1187 !decide("auto", true) && decide("always", true)
1188 }
1189
1190 #[test]
1194 fn slots_do_not_share_a_buffer() {
1195 let stage = Stage::begin(&test_settings(), &["a", "b"]);
1196 std::thread::scope(|scope| {
1197 for idx in 0..2 {
1198 let stage = Arc::clone(&stage);
1199 scope.spawn(move || {
1200 let _guard = stage.enter(idx);
1201 for i in 0..50 {
1202 say(&format!("check-{idx} line-{i}"));
1203 std::thread::yield_now();
1204 }
1205 });
1206 }
1207 });
1208 let slots = stage.slots.lock().unwrap();
1209 for idx in 0..2 {
1210 let text = String::from_utf8(slots[idx].buf.clone()).unwrap();
1211 assert_eq!(text.lines().count(), 50);
1212 assert!(
1213 text.lines()
1214 .all(|l| l.starts_with(&format!("check-{idx} "))),
1215 "a foreign line landed in slot {idx}"
1216 );
1217 }
1218 }
1219
1220 #[test]
1222 fn no_sink_means_no_capture() {
1223 let stage = Stage::begin(&test_settings(), &["a"]);
1224 say("goes to stdout, not to a slot");
1225 let slots = stage.slots.lock().unwrap();
1226 assert!(slots[0].buf.is_empty());
1227 }
1228
1229 #[test]
1232 fn a_finished_slot_takes_no_more_writes() {
1233 let stage = Stage::begin(&test_settings(), &["a"]);
1234 stage.append_raw(0, b"before\n");
1235 stage.finish(&test_settings(), 0, None);
1236 stage.append_raw(0, b"after\n");
1237 let slots = stage.slots.lock().unwrap();
1238 assert!(slots[0].buf.is_empty(), "a write landed after finish");
1239 }
1240
1241 #[test]
1244 fn a_slot_name_is_sanitised_at_begin() {
1245 let stage = Stage::begin(&test_settings(), &["evil\u{1b}[2Jname\rhere"]);
1246 let slots = stage.slots.lock().unwrap();
1247 assert!(!slots[0].name.contains('\u{1b}'), "{:?}", slots[0].name);
1248 assert!(!slots[0].name.contains('\r'), "{:?}", slots[0].name);
1249 }
1250
1251 #[test]
1254 fn a_slot_name_drops_the_stage_prefix() {
1255 let stage = Stage::begin(
1256 &test_settings(),
1257 &["pre-commit-clippy", "pre-push-run-tests", "bare"],
1258 );
1259 let slots = stage.slots.lock().unwrap();
1260 assert_eq!(slots[0].name, "clippy");
1261 assert_eq!(slots[1].name, "run-tests");
1262 assert_eq!(slots[2].name, "bare");
1263 }
1264
1265 fn row(name: &str, elapsed: f64) -> Row {
1266 Row {
1267 name: name.into(),
1268 elapsed,
1269 quiet: 0.0,
1270 still: 0.0,
1271 cpu: RowCpu::None,
1272 wait: None,
1273 load: None,
1274 queued: None,
1275 }
1276 }
1277
1278 #[test]
1281 fn a_queued_check_says_it_waits_for_a_host_slot() {
1282 let mut r = row("pre-push-run-tests-js", 42.0);
1283 r.queued = Some((42.0, 2));
1284 let text = region(&[r.clone()], 80, B);
1285 assert!(text.contains("· queued 42s (slots 2/2)"), "{text:?}");
1286 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1287 r.elapsed = 60.0;
1288 r.quiet = 60.0;
1289 r.queued = Some((60.0, 2));
1290 let beat = beat_line(&r, false, B, false);
1291 assert_eq!(
1292 beat,
1293 " … pre-push-run-tests-js still running: 1m00s, last output 1m00s ago, \
1294 queued 1m00s for a host slot (amont.hostSlots 2)\n"
1295 );
1296 }
1297
1298 #[test]
1302 fn a_load_stretched_budget_is_shown_with_its_load() {
1303 let mut r = row("pre-push-run-tests-js", 240.0);
1304 r.quiet = 130.0;
1305 r.still = 130.0;
1306 r.cpu = RowCpu::Idle;
1307 r.load = Some((
1308 468,
1309 crate::load::Load {
1310 avg1_milli: 31_200,
1311 cores: 8,
1312 },
1313 3900,
1314 ));
1315 let text = region(&[r.clone()], 80, B);
1316 assert!(text.contains("· quiet 2m10s/7m48s (load ×3.9)"), "{text:?}");
1317 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1318 let beat = beat_line(&r, false, B, false);
1319 assert!(
1320 beat.ends_with(
1321 ", CPU idle 2m10s, budget 7m48s (load avg 31.2 on 8 cores, ×3.9 — amont.idleLoadScale)\n"
1322 ),
1323 "{beat:?}"
1324 );
1325 let first = flat(&beat_line(&r, true, B, false));
1326 assert!(
1327 first.contains("killed after 7m48s with no output and under 0.1 core of CPU"),
1328 "{first:?}"
1329 );
1330 }
1331
1332 const B: Budgets = Budgets {
1333 idle: 120,
1334 ceiling: 3600,
1335 lock_wait: 600,
1336 extended: 480,
1337 };
1338
1339 #[test]
1343 fn an_unmeasured_check_is_shown_against_the_extended_budget() {
1344 let mut r = row("pre-push-run-tests-js", 240.0);
1345 r.quiet = 130.0;
1346 r.still = 130.0;
1347 r.cpu = RowCpu::Unmeasured;
1348 let text = region(&[r.clone()], 80, B);
1349 assert!(
1350 text.contains("· quiet 2m10s/8m00s (CPU unmeasured)"),
1351 "{text:?}"
1352 );
1353 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1354 let beat = beat_line(&r, false, B, false);
1355 assert!(
1356 beat.ends_with(", CPU unmeasured — extended budget 8m00s\n"),
1357 "{beat:?}"
1358 );
1359 let first = flat(&beat_line(&r, true, B, false));
1360 assert!(
1361 first.contains(
1362 "killed after 8m00s with no output while its CPU is unmeasured, or 1h00m in total"
1363 ),
1364 "{first:?}"
1365 );
1366 assert!(first.contains("amont.idleLoadScale"), "{first:?}");
1367 }
1368
1369 #[test]
1373 fn a_declared_wait_is_shown_against_its_own_budget() {
1374 use crate::hooks::wait::{CargoLockWhat, WaitKind};
1375 let mut r = row("pre-push-run-tests-js", 240.0);
1376 r.quiet = 130.0;
1377 r.still = 0.0;
1378 r.cpu = RowCpu::Unmeasured;
1379 r.wait = Some((WaitKind::CargoLock(CargoLockWhat::BuildDirectory), 90.0));
1380 let text = region(&[r.clone()], 80, B);
1381 assert!(text.contains("· cargo lock 1m30s/10m00s"), "{text:?}");
1382 assert!(!text.contains("quiet"), "no silence countdown: {text:?}");
1383 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1384 let until = Budgets { lock_wait: 0, ..B };
1385 let text = region(&[r.clone()], 80, until);
1386 assert!(
1387 text.contains("· cargo lock 1m30s") && !text.contains("1m30s/"),
1388 "{text:?}"
1389 );
1390
1391 let beat = beat_line(&r, false, B, false);
1392 assert_eq!(
1393 beat,
1394 " … pre-push-run-tests-js still running: 4m00s, last output 2m10s ago, \
1395 waiting for the cargo lock on the build directory 1m30s (amont.lockWait 10m00s)\n"
1396 );
1397 let beat = beat_line(&r, false, until, false);
1398 assert!(
1399 beat.contains("(amont.lockWait 0: until the ceiling)"),
1400 "{beat:?}"
1401 );
1402 let first = flat(&beat_line(&r, true, B, false));
1405 assert!(
1406 first.contains("(killed after 10m00s in the wait, or 1h00m in total — amont.lockWait"),
1407 "{first:?}"
1408 );
1409 assert!(!first.contains("unmeasured"), "{first:?}");
1410 }
1411
1412 #[test]
1415 fn frames_advance_with_time() {
1416 let a = region(&[row("clippy", 0.0)], 80, B);
1417 let b = region(&[row("clippy", 0.1)], 80, B);
1418 let c = region(&[row("clippy", 1.0)], 80, B);
1419 assert_ne!(a.chars().next(), b.chars().next());
1420 assert_eq!(a.chars().next(), c.chars().next(), "10 frames per second");
1421 }
1422
1423 #[test]
1426 fn region_lines_align() {
1427 let text = region(&[row("a", 0.0), row("longer-name", 0.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 let text = region(&[row("a", 3.2), row("b", 492.0)], 80, B);
1431 let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
1432 assert_eq!(widths[0], widths[1], "{text:?}");
1433 assert!(text.contains("8m12s"), "{text:?}");
1434 }
1435
1436 #[test]
1438 fn region_caps_and_counts_the_rest() {
1439 let entries: Vec<Row> = (0..13).map(|i| row(&format!("check-{i}"), 0.0)).collect();
1440 let text = region(&entries, 80, B);
1441 assert_eq!(text.lines().count(), MAX_LINES + 1);
1442 assert!(text.ends_with("… and 1 more\n"), "{text:?}");
1443 }
1444
1445 #[test]
1448 fn region_respects_width() {
1449 let text = region(&[row("a-name-much-longer-than-the-terminal", 0.0)], 20, B);
1450 assert!(text.lines().all(|l| l.chars().count() <= 20), "{text:?}");
1451 }
1452
1453 #[test]
1455 fn an_empty_region_is_empty() {
1456 assert_eq!(region(&[], 80, B), "");
1457 }
1458
1459 #[test]
1463 fn a_quiet_check_shows_its_silence_against_the_budget() {
1464 let mut r = row("cargo-test", 300.0);
1465 r.quiet = 5.0;
1466 assert!(!region(&[r.clone()], 80, B).contains("quiet"));
1467 r.quiet = 45.0;
1468 let text = region(&[r.clone()], 80, B);
1469 assert!(text.contains("quiet 45s/2m00s"), "{text:?}");
1470 let off = Budgets { idle: 0, ..B };
1471 let text = region(&[r], 80, off);
1472 assert!(
1473 text.contains("quiet 45s") && !text.contains('/'),
1474 "{text:?}"
1475 );
1476 }
1477
1478 #[test]
1483 fn a_quiet_busy_check_shows_cores_and_an_idle_one_counts_down_the_still_time() {
1484 let mut r = row("vitest-workspace", 240.0);
1485 r.quiet = 130.0;
1486 r.still = 130.0;
1487 r.cpu = RowCpu::Busy(3900);
1488 let busy = region(&[r.clone()], 80, B);
1489 assert!(busy.contains("· quiet 2m10s · ~3.9 cores"), "{busy:?}");
1490 assert!(
1491 !busy.contains("/2m00s"),
1492 "no countdown while busy: {busy:?}"
1493 );
1494
1495 r.cpu = RowCpu::Idle;
1496 r.still = 40.0;
1497 let idle = region(&[r.clone()], 80, B);
1498 assert!(idle.contains("· quiet 2m10s · idle 40s/2m00s"), "{idle:?}");
1499
1500 r.cpu = RowCpu::Unmeasured;
1501 r.still = 130.0;
1502 let plain = region(&[r], 80, B);
1503 assert!(
1504 plain.contains("· quiet 2m10s/8m00s (CPU unmeasured)"),
1505 "{plain:?}"
1506 );
1507
1508 for text in [busy, idle, plain] {
1509 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1510 }
1511 }
1512
1513 #[test]
1516 fn a_heartbeat_appends_the_cpu_state_after_an_unchanged_prefix() {
1517 let mut r = row("vitest", 240.0);
1518 r.quiet = 130.0;
1519 r.still = 40.0;
1520 let prefix = " … vitest still running: 4m00s, last output 2m10s ago";
1521 for (cpu, suffix) in [
1522 (RowCpu::Busy(3900), ", busy ~3.9 cores\n"),
1523 (RowCpu::Idle, ", CPU idle 40s\n"),
1524 (
1525 RowCpu::Unmeasured,
1526 ", CPU unmeasured — extended budget 8m00s\n",
1527 ),
1528 (RowCpu::None, "\n"),
1529 (RowCpu::NotSampled, "\n"),
1530 ] {
1531 r.cpu = cpu;
1532 assert_eq!(beat_line(&r, false, B, false), format!("{prefix}{suffix}"));
1533 }
1534 }
1535
1536 #[test]
1538 fn the_first_beat_states_the_rule_in_force() {
1539 let mut r = row("vitest", 60.0);
1540 r.cpu = RowCpu::Idle;
1541 let sampled = beat_line(&r, true, B, false);
1542 assert!(
1543 flat(&sampled).contains(
1544 "killed after 2m00s with no output and under 0.1 core of CPU, or 1h00m in total"
1545 ),
1546 "{sampled:?}"
1547 );
1548 r.cpu = RowCpu::NotSampled;
1549 let not = beat_line(&r, true, B, false);
1550 assert!(
1551 not.contains("2m00s of silence or 1h00m in total"),
1552 "{not:?}"
1553 );
1554 assert!(
1555 not.contains("CPU not sampled here, silence alone counts"),
1556 "{not:?}"
1557 );
1558 }
1559
1560 #[test]
1563 fn the_ceiling_shows_only_when_it_is_near() {
1564 assert!(!region(&[row("cargo-test", 1000.0)], 80, B).contains("/1h00m"));
1565 let text = region(&[row("cargo-test", 3000.0)], 80, B);
1566 assert!(text.contains("50m00s/1h00m"), "{text:?}");
1567 let off = Budgets { ceiling: 0, ..B };
1568 assert!(!region(&[row("cargo-test", 3000.0)], 80, off).contains("/"));
1569 }
1570
1571 #[test]
1575 fn a_heartbeat_names_the_budgets_once() {
1576 let mut r = row("cargo-test", 60.0);
1577 r.quiet = 2.0;
1578 let first = beat_line(&r, true, B, false);
1579 assert!(
1580 first.contains("cargo-test still running: 1m00s"),
1581 "{first:?}"
1582 );
1583 assert!(first.contains("last output 2s ago"), "{first:?}");
1584 assert!(
1585 first.contains("2m00s of silence or 1h00m in total"),
1586 "{first:?}"
1587 );
1588 assert!(first.contains("amont.idleTimeout"), "{first:?}");
1589 let later = beat_line(&r, false, B, false);
1590 assert!(!later.contains("amont.idleTimeout"), "{later:?}");
1591 let off = beat_line(
1592 &r,
1593 true,
1594 Budgets {
1595 idle: 0,
1596 ceiling: 0,
1597 lock_wait: 0,
1598 extended: 0,
1599 },
1600 false,
1601 );
1602 assert!(off.contains("off of silence or off in total"), "{off:?}");
1603 }
1604
1605 fn flat(line: &str) -> String {
1612 line.split_whitespace().collect::<Vec<_>>().join(" ")
1613 }
1614
1615 #[test]
1622 fn a_long_push_gate_is_told_what_it_is_sitting_on() {
1623 let r = row("cargo-test", 60.0);
1624
1625 let pushing = flat(&beat_line(&r, true, B, true));
1626 assert!(
1627 pushing.contains("A remote may close it first"),
1628 "{pushing:?}"
1629 );
1630 assert!(pushing.contains("Connection reset by peer"), "{pushing:?}");
1631 assert!(
1632 pushing.contains("Moving a gate entry earlier"),
1633 "{pushing:?}"
1634 );
1635
1636 let later = flat(&beat_line(&r, false, B, true));
1638 assert!(!later.contains("close it first"), "{later:?}");
1639
1640 let committing = flat(&beat_line(&r, true, B, false));
1642 assert!(!committing.contains("close it first"), "{committing:?}");
1643 }
1644
1645 #[test]
1654 fn the_push_note_does_not_recommend_ssh_keepalive() {
1655 let r = row("cargo-test", 60.0);
1656 let pushing = flat(&beat_line(&r, true, B, true));
1657 assert!(
1658 !pushing.contains("ServerAlive"),
1659 "keepalive was already on when this failed; recommending it \
1660 would be useless advice: {pushing:?}"
1661 );
1662 assert!(
1663 pushing.contains("ssh keepalive does not prevent this"),
1664 "say so, rather than leaving the reader to try it: {pushing:?}"
1665 );
1666 }
1667}