amont_runtime/live.rs
1//! One check, one block — and while it runs, one line: per-check output
2//! capture plus a live progress region for the concurrent stage.
3//!
4//! Twenty checks used to print straight to inherited stdio from their own
5//! threads, so two failing linters shuffled their lines together and the
6//! reader un-shuffled them by hand — the dispatcher's roll-up existed partly
7//! to apologise for it. Now every check writes into its own slot, and a
8//! completed check's output reaches stdout as ONE locked write: contiguous,
9//! whatever the other nineteen were doing.
10//!
11//! Three writers feed a slot:
12//!
13//! 1. The check's own thread, through [`say`] — which is what
14//! `common::ok/fail/warn` call. A thread with no slot installed (commit-msg,
15//! `amont install`, the dispatcher itself) prints directly, exactly as
16//! before; nothing outside a stage changes.
17//! 2. A captured child's reader threads, through [`Stage::append_raw`] —
18//! they are not the check's thread, so the thread-local cannot carry the
19//! routing; the `Arc` is captured before the spawn instead.
20//! 3. Nobody else. The dispatcher's own lines (skips, pins, the roll-up)
21//! happen strictly before or after the fan-out and stay direct.
22//!
23//! Order across checks is COMPLETION order — deterministic per block, not
24//! per stage, which is the same nondeterminism the interleaved version had
25//! without the shuffling. `amont.progress false` switches the whole
26//! mechanism off and restores raw streaming for anyone who wants to watch a
27//! tool write in real time.
28//!
29//! # The region
30//!
31//! When stderr is a real terminal ([`watching`]) the stage also paints a
32//! live region UNDER the finished blocks: one line per running check —
33//! braille spinner, name, elapsed — repainted every 80ms by a ticker
34//! thread, shrinking as checks finish, gone without a trace when the stage
35//! ends. Blocks go to stdout, the region to stderr; both feed one tty, and
36//! every write to either happens under the same [`Stage::out`] lock, so a
37//! block never tears a repaint in half. Piped, redirected, `TERM=dumb`, or
38//! CI: [`watching`] is false, no ticker starts, and the region costs
39//! nothing — which is also why the test suite (piped stdio throughout)
40//! exercises capture but never the paint.
41
42use 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
48/// The fleet spinner's frames (progress.rs) — cycled by elapsed time, so a
49/// frame needs no state beyond the clock.
50const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
51
52/// The region never grows past this many check lines; the rest fold into
53/// one `… and N more`. Twelve is the whole default fleet on one screen.
54const MAX_LINES: usize = 12;
55
56/// One check's place in the stage.
57struct Slot {
58 /// Sanitised at [`Stage::begin`]: a manifest-declared name is
59 /// repo-derived text and the region writes it to a live terminal.
60 name: String,
61 /// Restamped by [`Stage::enter`], so a serial stage (pre-push) times
62 /// each check from its own start, not the stage's.
63 started: Instant,
64 /// The last byte or line that landed in `buf` — what the region's
65 /// `quiet` figure and the heartbeat's `last output` read.
66 last_output: Instant,
67 /// Elapsed seconds at which the non-tty heartbeat next speaks.
68 next_beat: u64,
69 buf: Vec<u8>,
70 /// Entered and not yet finished — the region shows exactly these.
71 running: bool,
72 done: bool,
73}
74
75/// A running stage: the slots, and the one lock every terminal write inside
76/// the stage goes through.
77pub struct Stage {
78 slots: Mutex<Vec<Slot>>,
79 /// Serialises block emission and region repaints; the value is how many
80 /// region lines are currently painted (what an erase must remove).
81 out: Mutex<usize>,
82 /// Painting at all? [`enabled`] && [`watching`], decided once at begin.
83 live: bool,
84 /// Is this the PUSH stage? Read by the heartbeat, which has something to
85 /// say about a long gate there and nothing to say about one at commit
86 /// time — see [`beat_line`]. Derived from the names, which already
87 /// carry the trigger.
88 on_push: bool,
89 stop: AtomicBool,
90}
91
92thread_local! {
93 /// Where [`say`] routes on THIS thread: a stage and a slot index.
94 static SINK: RefCell<Option<(Arc<Stage>, usize)>> = const { RefCell::new(None) };
95}
96
97impl Stage {
98 /// A stage over `names`, in dispatch order. Does nothing visible until
99 /// checks start entering (the region) or finishing (the blocks).
100 pub fn begin(settings: &crate::config::Settings, names: &[&str]) -> Arc<Stage> {
101 let now = Instant::now();
102 let stage = Arc::new(Stage {
103 slots: Mutex::new(
104 names
105 .iter()
106 .map(|n| Slot {
107 // Every name in a stage carries the stage's own
108 // prefix ("pre-commit-clippy"); the region drops it
109 // — twelve identical prefixes say nothing.
110 name: crate::ui::sanitize(
111 n.strip_prefix("pre-commit-")
112 .or_else(|| n.strip_prefix("pre-push-"))
113 .unwrap_or(n),
114 ),
115 started: now,
116 last_output: now,
117 next_beat: HEARTBEAT_SECS,
118 buf: Vec::new(),
119 running: false,
120 done: false,
121 })
122 .collect(),
123 ),
124 out: Mutex::new(0),
125 live: enabled(settings) && watching(),
126 // The names arrive fully qualified and the loop above has
127 // already had to strip the trigger to display them, so the
128 // stage can answer this without dispatch passing anything in.
129 on_push: names.iter().any(|n| n.starts_with("pre-push-")),
130 stop: AtomicBool::new(false),
131 });
132 if stage.live {
133 // The ticker holds a Weak: the stage dropping is what ends it,
134 // so a paint can never outlive the region's owner.
135 let weak = Arc::downgrade(&stage);
136 let own = settings.for_thread();
137 let _ = std::thread::Builder::new()
138 .name("amont-live".into())
139 .spawn(move || tick(own, weak));
140 } else if enabled(settings) {
141 // Nobody is watching a terminal — an agent, CI, a pipe — and a
142 // captured check shows nothing until it finishes. The heartbeat
143 // is the one line a minute that says it is alive, which is the
144 // difference between "wait" and "kill it" for whoever is on the
145 // other end of the pipe.
146 let weak = Arc::downgrade(&stage);
147 let own = settings.for_thread();
148 let _ = std::thread::Builder::new()
149 .name("amont-heartbeat".into())
150 .spawn(move || heartbeat(own, weak));
151 }
152 stage
153 }
154
155 /// Route this thread's [`say`] calls into slot `idx` until the guard
156 /// drops. Installed by the dispatcher around each `check.run`. Also
157 /// starts the slot's clock and puts it in the region.
158 pub fn enter(self: &Arc<Stage>, idx: usize) -> SinkGuard {
159 {
160 let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
161 if let Some(slot) = slots.get_mut(idx) {
162 slot.running = true;
163 slot.started = Instant::now();
164 slot.last_output = slot.started;
165 slot.next_beat = HEARTBEAT_SECS;
166 }
167 }
168 SINK.with(|s| *s.borrow_mut() = Some((Arc::clone(self), idx)));
169 SinkGuard
170 }
171
172 /// Append raw bytes (a captured child's output) to slot `idx`.
173 pub fn append_raw(&self, idx: usize, bytes: &[u8]) {
174 let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
175 if let Some(slot) = slots.get_mut(idx) {
176 if !slot.done {
177 slot.buf.extend_from_slice(bytes);
178 slot.last_output = Instant::now();
179 }
180 }
181 }
182
183 fn append_line(&self, idx: usize, line: &str) {
184 let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
185 if let Some(slot) = slots.get_mut(idx) {
186 if !slot.done {
187 slot.buf.extend_from_slice(line.as_bytes());
188 slot.buf.push(b'\n');
189 slot.last_output = Instant::now();
190 }
191 }
192 }
193
194 /// The check is over: emit everything it said as ONE contiguous write,
195 /// with the region lifted out of the way first and repainted after —
196 /// blocks pile up above, spinners stay below.
197 ///
198 /// Called by the dispatcher after `check.run` returns (still on the
199 /// check's thread, so a torn-down thread cannot strand a buffer — the
200 /// same `catch_unwind` that feeds the dead-check outcome runs first).
201 pub fn finish(&self, settings: &crate::config::Settings, idx: usize) {
202 let block = {
203 let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
204 let Some(slot) = slots.get_mut(idx) else {
205 return;
206 };
207 slot.done = true;
208 slot.running = false;
209 std::mem::take(&mut slot.buf)
210 };
211 if block.is_empty() && !self.live {
212 return;
213 }
214 let mut drawn = self.out.lock().unwrap_or_else(|p| p.into_inner());
215 if !block.is_empty() {
216 if *drawn > 0 {
217 let mut err = std::io::stderr().lock();
218 let _ = write!(err, "\x1b[{}A\x1b[J", *drawn);
219 let _ = err.flush();
220 *drawn = 0;
221 }
222 let stdout = std::io::stdout();
223 let mut handle = stdout.lock();
224 let _ = handle.write_all(&block);
225 let _ = handle.flush();
226 }
227 self.repaint(settings, &mut drawn);
228 }
229
230 /// Erase and redraw the region in one stderr write. Lock order is
231 /// `out` → `slots`, everywhere — never the reverse.
232 fn repaint(&self, settings: &crate::config::Settings, drawn: &mut usize) {
233 if !self.live {
234 return;
235 }
236 let entries: Vec<Row> = {
237 let slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
238 let now = Instant::now();
239 slots
240 .iter()
241 .filter(|s| s.running && !s.done)
242 .map(|s| Row {
243 name: s.name.clone(),
244 elapsed: now.duration_since(s.started).as_secs_f64(),
245 quiet: now.duration_since(s.last_output).as_secs_f64(),
246 })
247 .collect()
248 };
249 let text = region(&entries, term_width(), budgets(settings));
250 let mut paint = String::new();
251 if *drawn > 0 {
252 paint.push_str(&format!("\x1b[{}A\x1b[J", *drawn));
253 }
254 paint.push_str(&text);
255 if paint.is_empty() {
256 return;
257 }
258 let mut err = std::io::stderr().lock();
259 let _ = err.write_all(paint.as_bytes());
260 let _ = err.flush();
261 *drawn = text.matches('\n').count();
262 }
263}
264
265impl Drop for Stage {
266 /// The stage's end erases whatever the region still shows — a Block
267 /// verdict, a panic on the dispatcher path, anything: no spinner junk
268 /// above the roll-up. (`get_mut`: dropping proves no other thread holds
269 /// the stage, so the locks are free.)
270 fn drop(&mut self) {
271 self.stop.store(true, Ordering::Relaxed);
272 if !self.live {
273 return;
274 }
275 let drawn = self.out.get_mut().unwrap_or_else(|p| p.into_inner());
276 if *drawn > 0 {
277 let mut err = std::io::stderr().lock();
278 let _ = write!(err, "\x1b[{}A\x1b[J", *drawn);
279 let _ = err.flush();
280 *drawn = 0;
281 }
282 }
283}
284
285/// The ticker: repaint every 80ms until the stage drops or tells it to
286/// stop. Holds only a `Weak`, so it can never keep a finished stage alive.
287/// Owns its `Settings` (see [`crate::config::Settings::for_thread`]): a
288/// spawned thread is `'static`, and the budgets must be read lazily, not
289/// pre-resolved at the spawn.
290fn tick(settings: crate::config::Settings, weak: Weak<Stage>) {
291 loop {
292 std::thread::sleep(std::time::Duration::from_millis(80));
293 let Some(stage) = weak.upgrade() else { return };
294 if stage.stop.load(Ordering::Relaxed) {
295 return;
296 }
297 let mut drawn = stage.out.lock().unwrap_or_else(|p| p.into_inner());
298 stage.repaint(&settings, &mut drawn);
299 }
300}
301
302/// One running check, as the region and the heartbeat see it.
303#[derive(Debug, Clone)]
304pub struct Row {
305 pub name: String,
306 /// Seconds since the check entered.
307 pub elapsed: f64,
308 /// Seconds since it last wrote anything.
309 pub quiet: f64,
310}
311
312/// The two clocks, as the region annotates them: `(idle, ceiling)` in
313/// seconds, `0` for off.
314#[derive(Debug, Clone, Copy)]
315pub struct Budgets {
316 pub idle: u64,
317 pub ceiling: u64,
318}
319
320fn budgets(settings: &crate::config::Settings) -> Budgets {
321 Budgets {
322 idle: crate::hooks::common::idle_timeout(settings),
323 ceiling: crate::hooks::common::check_timeout(settings),
324 }
325}
326
327/// How long a check must be quiet before the region says so. A test suite
328/// pauses this long between crates without anything being wrong; past it,
329/// the reader wants to know the silence is being counted.
330const QUIET_NOTE_SECS: f64 = 30.0;
331
332/// The non-tty heartbeat's period: one line a minute per running check.
333const HEARTBEAT_SECS: u64 = 60;
334
335/// Elapsed time in a fixed six-column figure: ` 3.2s` under a minute,
336/// `8m12s` and `1h02m` above, so the column stays aligned as the suite
337/// crosses the minute.
338fn elapsed_column(secs: f64) -> String {
339 if secs < 60.0 {
340 format!("{secs:>5.1}s")
341 } else {
342 format!("{:>6}", crate::hooks::common::human_secs(secs as u64))
343 }
344}
345
346/// The region's text: one `⠹ name 12.3s` line per running check, capped at
347/// [`MAX_LINES`] plus a `… and N more` overflow line. Pure — the ticker is
348/// a thin shell around this, and the tests drive it directly.
349///
350/// Two annotations, each only when it carries news: `· quiet 45s/2m` once
351/// a check has been silent past [`QUIET_NOTE_SECS`] (with the silence
352/// budget it is counting toward, when there is one), and `· 48m/60m` once
353/// elapsed passes 80% of the ceiling — the cliff, shown before the fall.
354fn region(entries: &[Row], width: usize, budgets: Budgets) -> String {
355 if entries.is_empty() {
356 return String::new();
357 }
358 let pad = entries
359 .iter()
360 .take(MAX_LINES)
361 .map(|r| r.name.chars().count())
362 .max()
363 .unwrap_or(0);
364 let mut out = String::new();
365 for row in entries.iter().take(MAX_LINES) {
366 let frame = FRAMES[((row.elapsed * 10.0) as usize) % FRAMES.len()];
367 let name = &row.name;
368 let mut line = format!("{frame} {name:<pad$} {}", elapsed_column(row.elapsed));
369 if row.quiet >= QUIET_NOTE_SECS {
370 let quiet = crate::hooks::common::human_secs(row.quiet as u64);
371 if budgets.idle > 0 {
372 line.push_str(&format!(
373 " · quiet {quiet}/{}",
374 crate::hooks::common::human_secs(budgets.idle)
375 ));
376 } else {
377 line.push_str(&format!(" · quiet {quiet}"));
378 }
379 }
380 if budgets.ceiling > 0 && row.elapsed >= 0.8 * budgets.ceiling as f64 {
381 line.push_str(&format!(
382 " · {}/{}",
383 crate::hooks::common::human_secs(row.elapsed as u64),
384 crate::hooks::common::human_secs(budgets.ceiling)
385 ));
386 }
387 if line.chars().count() > width {
388 out.extend(line.chars().take(width));
389 } else {
390 out.push_str(&line);
391 }
392 out.push('\n');
393 }
394 if entries.len() > MAX_LINES {
395 out.push_str(&format!("… and {} more\n", entries.len() - MAX_LINES));
396 }
397 out
398}
399
400/// The heartbeat: once a minute, for each check still running, one plain
401/// line on stderr — elapsed, and how long since it last said anything.
402/// Not a region: nothing is erased or repainted, because nobody is looking
403/// at a cursor; whoever reads this reads a log.
404///
405/// The first beat for a check also names the two budgets, once, so the
406/// reader can tell how far it is from being killed without opening the
407/// docs. Written under the same `out` lock as the blocks, so a beat never
408/// lands inside one.
409/// Owns its `Settings` for the same reason [`tick`] does.
410fn heartbeat(settings: crate::config::Settings, weak: Weak<Stage>) {
411 loop {
412 std::thread::sleep(std::time::Duration::from_secs(1));
413 let Some(stage) = weak.upgrade() else { return };
414 if stage.stop.load(Ordering::Relaxed) {
415 return;
416 }
417 let due: Vec<(Row, bool)> = {
418 let mut slots = stage.slots.lock().unwrap_or_else(|p| p.into_inner());
419 let now = Instant::now();
420 let mut due = Vec::new();
421 for s in slots.iter_mut().filter(|s| s.running && !s.done) {
422 let elapsed = now.duration_since(s.started).as_secs();
423 if elapsed >= s.next_beat {
424 let first = s.next_beat == HEARTBEAT_SECS;
425 s.next_beat += HEARTBEAT_SECS;
426 due.push((
427 Row {
428 name: s.name.clone(),
429 elapsed: elapsed as f64,
430 quiet: now.duration_since(s.last_output).as_secs_f64(),
431 },
432 first,
433 ));
434 }
435 }
436 due
437 };
438 if due.is_empty() {
439 continue;
440 }
441 let text: String = due
442 .iter()
443 .map(|(row, first)| beat_line(row, *first, budgets(&settings), stage.on_push))
444 .collect();
445 let _guard = stage.out.lock().unwrap_or_else(|p| p.into_inner());
446 let mut err = std::io::stderr().lock();
447 let _ = err.write_all(text.as_bytes());
448 let _ = err.flush();
449 }
450}
451
452/// One heartbeat line. Pure, for the tests.
453///
454/// On the FIRST beat of a PUSH gate it also names something no other part of
455/// the system is placed to explain. `git push` opens its connection to the
456/// remote, reads the remote refs — which is where the `pre-push` hook's own
457/// stdin comes from — and only then calls the hook. The connection is
458/// therefore already open and goes idle for exactly as long as the gate
459/// runs, and a remote may close it before the gate finishes. git then
460/// reports `Connection reset by peer`, which reads as a network fault and
461/// says nothing about the seven minutes that caused it.
462///
463/// The note does NOT recommend ssh keepalive, and that omission is
464/// deliberate: `ServerAliveInterval 60` was already in force on the machine
465/// where this was diagnosed, and GitHub reset the connection anyway.
466/// Whatever the remote is measuring, it is not packets. Recommending it
467/// would be a confident instruction to change a setting that is probably
468/// already on and cannot help, so the note says so and points at the thing
469/// that does work.
470///
471/// Only on a first beat, so it is said once; only on a push, so a commit
472/// gate never hears it. A first beat is a check that has already run a full
473/// minute, which is the population at risk — no threshold to invent.
474fn beat_line(row: &Row, first: bool, budgets: Budgets, on_push: bool) -> String {
475 use crate::hooks::common::human_secs;
476 let mut line = format!(
477 " … {} still running: {}, last output {} ago",
478 row.name,
479 human_secs(row.elapsed as u64),
480 human_secs(row.quiet as u64)
481 );
482 if first {
483 let idle = match budgets.idle {
484 0 => "off".to_string(),
485 s => human_secs(s),
486 };
487 let ceiling = match budgets.ceiling {
488 0 => "off".to_string(),
489 s => human_secs(s),
490 };
491 line.push_str(&format!(
492 " (killed after {idle} of silence or {ceiling} in total — amont.idleTimeout / amont.timeout)"
493 ));
494 if on_push {
495 // `concat!`, not a `\`-continued literal: a continuation keeps
496 // the next line's indentation, which turns the message into runs
497 // of spaces. Each line is its own literal and the newlines are
498 // written down, so what is here is what a reader sees.
499 line.push_str(concat!(
500 "\n git opened its connection to the remote before calling this",
501 "\n gate, and it stays idle until the gate finishes. A remote may",
502 "\n close it first — GitHub does — and the push then fails with",
503 "\n \"Connection reset by peer\", naming the network rather than the",
504 "\n wait. ssh keepalive does not prevent this.",
505 "\n Declaring this check at pre-commit moves it off the push path —",
506 "\n see \"Moving a gate entry earlier\" in the docs.",
507 ));
508 }
509 }
510 line.push('\n');
511 line
512}
513
514/// `$COLUMNS` when it is exported and sane, else a conservative 100 — the
515/// region's lines are short and an ioctl is not worth its portability.
516///
517/// `pub` is now wider than it needs to be — the out-of-crate caller that
518/// justified it, `amont-agent`, is its own project and carries its own copy.
519/// Left public rather than narrowed in the same change that removed it.
520pub fn term_width() -> usize {
521 std::env::var("COLUMNS")
522 .ok()
523 .and_then(|c| c.parse::<usize>().ok())
524 .filter(|w| *w >= 20)
525 .unwrap_or(100)
526}
527
528/// Emits slot `idx`'s block when dropped — however the check's closure
529/// exits, a panic included: the partial output of a check that died still
530/// reaches the reader, above the dead-check verdict the runner fills in.
531pub struct FinishOnDrop<'a> {
532 stage: &'a Stage,
533 idx: usize,
534 /// Carried, because `Drop` takes no arguments and the finish paint
535 /// needs the budgets. Same lifetime as the stage it belongs to.
536 settings: &'a crate::config::Settings,
537}
538
539impl<'a> FinishOnDrop<'a> {
540 pub fn new(
541 settings: &'a crate::config::Settings,
542 stage: &'a Stage,
543 idx: usize,
544 ) -> FinishOnDrop<'a> {
545 FinishOnDrop {
546 stage,
547 idx,
548 settings,
549 }
550 }
551}
552
553impl Drop for FinishOnDrop<'_> {
554 fn drop(&mut self) {
555 self.stage.finish(self.settings, self.idx);
556 }
557}
558
559/// Uninstalls the thread's sink on drop, whatever path the check took out.
560pub struct SinkGuard;
561
562impl Drop for SinkGuard {
563 fn drop(&mut self) {
564 SINK.with(|s| *s.borrow_mut() = None);
565 }
566}
567
568/// The sink installed on THIS thread, if any — how a child-capture helper on
569/// the check's own thread learns where the reader threads should append.
570pub fn current_sink() -> Option<(Arc<Stage>, usize)> {
571 SINK.with(|s| s.borrow().clone())
572}
573
574/// One line of check output, wherever it should go.
575///
576/// THE funnel: `common::ok/fail/warn` call this, so a check's helper prints
577/// land in its slot during a stage and on stdout everywhere else. `line` is
578/// taken without a trailing newline, exactly like `println!`.
579pub fn say(line: &str) {
580 let routed = SINK.with(|s| {
581 s.borrow().as_ref().map(|(stage, idx)| {
582 stage.append_line(*idx, line);
583 })
584 });
585 if routed.is_none() {
586 println!("{line}");
587 }
588}
589
590/// `println!`, stage-aware: formats and routes through [`say`]. What every
591/// direct print inside a CHECK BODY becomes — a line printed raw from a
592/// check thread bypasses the slot and interleaves, which is the bug this
593/// module exists to close.
594#[macro_export]
595macro_rules! say {
596 ($($arg:tt)*) => {
597 $crate::live::say(&format!($($arg)*))
598 };
599}
600
601/// Should a check's SUCCESS line be swallowed?
602///
603/// A hook that passes says one line per check, and on a clean run that is the
604/// entire output: fourteen lines to say nothing happened. At a terminal those
605/// lines are the reassurance that the gate ran. Captured — an agent's tool
606/// result, a CI log — they are re-read on every later turn of the session and
607/// say no more the tenth time than the first.
608///
609/// So the setting names WHO is reading, not how loud to be:
610///
611/// - `auto` (default) — quiet when nobody is watching, verbose at a terminal.
612/// - `never` — every check says it passed, whoever is reading.
613/// - `always` — quiet everywhere.
614///
615/// `auto` is the default because the reader it costs nothing is the one at a
616/// terminal: `watching()` is true there, so a person sees exactly what they
617/// saw before. The reader it saves is the one who cannot skim — a captured
618/// log, an agent's tool result — and that reader was paying for fourteen
619/// lines of nothing on every turn of a session. A default that is free for
620/// one audience and compounding for the other is not a neutral default.
621///
622/// Only the success lines go. A failure, a warning, a check that could not
623/// run, a repaired file, and the blocked summary are printed under every
624/// setting: quiet is about the uneventful path, and nothing else.
625pub fn quiet(settings: &crate::config::Settings) -> bool {
626 *settings.quiet.get_or_init(|| {
627 decide(
628 crate::config::enumerated_or(settings, "amont.quiet", QUIET_VALUES, "auto"),
629 watching(),
630 )
631 })
632}
633
634pub const QUIET_VALUES: &[&str] = &["never", "auto", "always"];
635
636/// Pure, so the three-way decision is testable without a terminal or a config.
637fn decide(setting: &str, watching: bool) -> bool {
638 match setting {
639 "always" => true,
640 "auto" => !watching,
641 // `never`. A value `enumerated_or` rejected never reaches here — it
642 // complains and hands back the default, which is now `auto`.
643 _ => false,
644 }
645}
646
647/// Whether the capture mechanism is on at all. `amont.progress false` is the
648/// escape hatch back to raw streaming — one knob, read once.
649pub fn enabled(settings: &crate::config::Settings) -> bool {
650 *settings
651 .progress
652 .get_or_init(|| crate::config::boolean_or(settings, "amont.progress", true))
653}
654
655/// Is anyone watching? True only when stderr is a real terminal that speaks
656/// VT: not piped, not redirected, not `TERM=dumb` — and on Windows only
657/// with `TERM` actually set, because bare conhost may not interpret the
658/// cursor codes the region depends on. This is the paint gate; capture
659/// ([`enabled`]) does not consult it.
660pub fn watching() -> bool {
661 static WATCHING: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
662 *WATCHING.get_or_init(|| {
663 if !std::io::stderr().is_terminal() {
664 return false;
665 }
666 match std::env::var("TERM") {
667 Ok(term) => term != "dumb",
668 Err(_) => !cfg!(windows),
669 }
670 })
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676
677 fn test_settings() -> crate::config::Settings {
678 crate::config::Settings::default()
679 }
680
681 #[test]
682 fn quiet_asks_who_is_reading() {
683 assert!(!decide("never", true));
684 assert!(!decide("never", false));
685 assert!(always_and_auto_agree_at_a_terminal());
686 assert!(decide("auto", false), "captured: nobody is watching");
687 assert!(decide("always", true));
688 assert!(decide("always", false));
689 // An unreadable value has already been reported by `enumerated_or`,
690 // which hands back the default; silence is never assumed.
691 assert!(!decide("shhh", false));
692 }
693
694 fn always_and_auto_agree_at_a_terminal() -> bool {
695 !decide("auto", true) && decide("always", true)
696 }
697
698 /// The atomicity contract at the unit level: two threads writing
699 /// interleaved lines into their own slots come out as two contiguous
700 /// buffers, whatever the scheduler did.
701 #[test]
702 fn slots_do_not_share_a_buffer() {
703 let stage = Stage::begin(&test_settings(), &["a", "b"]);
704 std::thread::scope(|scope| {
705 for idx in 0..2 {
706 let stage = Arc::clone(&stage);
707 scope.spawn(move || {
708 let _guard = stage.enter(idx);
709 for i in 0..50 {
710 say(&format!("check-{idx} line-{i}"));
711 std::thread::yield_now();
712 }
713 });
714 }
715 });
716 let slots = stage.slots.lock().unwrap();
717 for idx in 0..2 {
718 let text = String::from_utf8(slots[idx].buf.clone()).unwrap();
719 assert_eq!(text.lines().count(), 50);
720 assert!(
721 text.lines()
722 .all(|l| l.starts_with(&format!("check-{idx} "))),
723 "a foreign line landed in slot {idx}"
724 );
725 }
726 }
727
728 /// A thread with no sink prints; its lines never land in anyone's slot.
729 #[test]
730 fn no_sink_means_no_capture() {
731 let stage = Stage::begin(&test_settings(), &["a"]);
732 say("goes to stdout, not to a slot");
733 let slots = stage.slots.lock().unwrap();
734 assert!(slots[0].buf.is_empty());
735 }
736
737 /// After finish, late writes are dropped rather than stranded — a child
738 /// reader thread that outlives its check must not corrupt a later block.
739 #[test]
740 fn a_finished_slot_takes_no_more_writes() {
741 let stage = Stage::begin(&test_settings(), &["a"]);
742 stage.append_raw(0, b"before\n");
743 stage.finish(&test_settings(), 0);
744 stage.append_raw(0, b"after\n");
745 let slots = stage.slots.lock().unwrap();
746 assert!(slots[0].buf.is_empty(), "a write landed after finish");
747 }
748
749 /// A repo-derived check name cannot smuggle control bytes onto a live
750 /// terminal: sanitised at begin, once, for every later paint.
751 #[test]
752 fn a_slot_name_is_sanitised_at_begin() {
753 let stage = Stage::begin(&test_settings(), &["evil\u{1b}[2Jname\rhere"]);
754 let slots = stage.slots.lock().unwrap();
755 assert!(!slots[0].name.contains('\u{1b}'), "{:?}", slots[0].name);
756 assert!(!slots[0].name.contains('\r'), "{:?}", slots[0].name);
757 }
758
759 /// Region names drop the stage's own prefix — it is the same twelve
760 /// characters on every line.
761 #[test]
762 fn a_slot_name_drops_the_stage_prefix() {
763 let stage = Stage::begin(
764 &test_settings(),
765 &["pre-commit-clippy", "pre-push-run-tests", "bare"],
766 );
767 let slots = stage.slots.lock().unwrap();
768 assert_eq!(slots[0].name, "clippy");
769 assert_eq!(slots[1].name, "run-tests");
770 assert_eq!(slots[2].name, "bare");
771 }
772
773 fn row(name: &str, elapsed: f64) -> Row {
774 Row {
775 name: name.into(),
776 elapsed,
777 quiet: 0.0,
778 }
779 }
780
781 const B: Budgets = Budgets {
782 idle: 120,
783 ceiling: 3600,
784 };
785
786 /// The spinner frame comes from the clock: different elapsed, different
787 /// frame; same elapsed, same frame.
788 #[test]
789 fn frames_advance_with_time() {
790 let a = region(&[row("clippy", 0.0)], 80, B);
791 let b = region(&[row("clippy", 0.1)], 80, B);
792 let c = region(&[row("clippy", 1.0)], 80, B);
793 assert_ne!(a.chars().next(), b.chars().next());
794 assert_eq!(a.chars().next(), c.chars().next(), "10 frames per second");
795 }
796
797 /// Names pad to a column so the elapsed figures align — across the
798 /// minute mark too, where the figure changes shape.
799 #[test]
800 fn region_lines_align() {
801 let text = region(&[row("a", 0.0), row("longer-name", 0.0)], 80, B);
802 let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
803 assert_eq!(widths[0], widths[1], "{text:?}");
804 let text = region(&[row("a", 3.2), row("b", 492.0)], 80, B);
805 let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
806 assert_eq!(widths[0], widths[1], "{text:?}");
807 assert!(text.contains("8m12s"), "{text:?}");
808 }
809
810 /// Thirteen running checks paint as twelve lines and one overflow.
811 #[test]
812 fn region_caps_and_counts_the_rest() {
813 let entries: Vec<Row> = (0..13).map(|i| row(&format!("check-{i}"), 0.0)).collect();
814 let text = region(&entries, 80, B);
815 assert_eq!(text.lines().count(), MAX_LINES + 1);
816 assert!(text.ends_with("… and 1 more\n"), "{text:?}");
817 }
818
819 /// A narrow terminal truncates rather than wraps — a wrapped region
820 /// line would break the erase arithmetic.
821 #[test]
822 fn region_respects_width() {
823 let text = region(&[row("a-name-much-longer-than-the-terminal", 0.0)], 20, B);
824 assert!(text.lines().all(|l| l.chars().count() <= 20), "{text:?}");
825 }
826
827 /// No running checks, no region — not even a blank line.
828 #[test]
829 fn an_empty_region_is_empty() {
830 assert_eq!(region(&[], 80, B), "");
831 }
832
833 /// Silence is annotated only once it is news, and names the budget it
834 /// counts toward — a check that just paused between crates says
835 /// nothing extra.
836 #[test]
837 fn a_quiet_check_shows_its_silence_against_the_budget() {
838 let mut r = row("cargo-test", 300.0);
839 r.quiet = 5.0;
840 assert!(!region(&[r.clone()], 80, B).contains("quiet"));
841 r.quiet = 45.0;
842 let text = region(&[r.clone()], 80, B);
843 assert!(text.contains("quiet 45s/2m00s"), "{text:?}");
844 let off = Budgets { idle: 0, ..B };
845 let text = region(&[r], 80, off);
846 assert!(
847 text.contains("quiet 45s") && !text.contains('/'),
848 "{text:?}"
849 );
850 }
851
852 /// The ceiling appears once a check is 80% of the way to it — the cliff,
853 /// shown before the fall — and never for a disabled ceiling.
854 #[test]
855 fn the_ceiling_shows_only_when_it_is_near() {
856 assert!(!region(&[row("cargo-test", 1000.0)], 80, B).contains("/1h00m"));
857 let text = region(&[row("cargo-test", 3000.0)], 80, B);
858 assert!(text.contains("50m00s/1h00m"), "{text:?}");
859 let off = Budgets { ceiling: 0, ..B };
860 assert!(!region(&[row("cargo-test", 3000.0)], 80, off).contains("/"));
861 }
862
863 /// The heartbeat says how long, how quiet, and — the first time — the
864 /// budgets, so a reader at the far end of a pipe can tell "wait" from
865 /// "kill it" without the docs.
866 #[test]
867 fn a_heartbeat_names_the_budgets_once() {
868 let mut r = row("cargo-test", 60.0);
869 r.quiet = 2.0;
870 let first = beat_line(&r, true, B, false);
871 assert!(
872 first.contains("cargo-test still running: 1m00s"),
873 "{first:?}"
874 );
875 assert!(first.contains("last output 2s ago"), "{first:?}");
876 assert!(
877 first.contains("2m00s of silence or 1h00m in total"),
878 "{first:?}"
879 );
880 assert!(first.contains("amont.idleTimeout"), "{first:?}");
881 let later = beat_line(&r, false, B, false);
882 assert!(!later.contains("amont.idleTimeout"), "{later:?}");
883 let off = beat_line(
884 &r,
885 true,
886 Budgets {
887 idle: 0,
888 ceiling: 0,
889 },
890 false,
891 );
892 assert!(off.contains("off of silence or off in total"), "{off:?}");
893 }
894
895 /// The message, with newlines and indentation flattened.
896 ///
897 /// The note is wrapped for a terminal, so a literal substring can fall
898 /// across a line break — asserting on `"may close it first"` failed for
899 /// no better reason than that `may` ended a line. These tests are about
900 /// what the message SAYS; re-wrapping it should not break them.
901 fn flat(line: &str) -> String {
902 line.split_whitespace().collect::<Vec<_>>().join(" ")
903 }
904
905 /// A long PUSH gate is told what it is sitting on; a commit gate is not.
906 ///
907 /// The three negatives matter as much as the positive. Said on every
908 /// beat it would be nagging; said at commit time it would be false —
909 /// there is no connection open — and a future refactor that wires
910 /// `on_push` to a constant would show up here and nowhere else.
911 #[test]
912 fn a_long_push_gate_is_told_what_it_is_sitting_on() {
913 let r = row("cargo-test", 60.0);
914
915 let pushing = flat(&beat_line(&r, true, B, true));
916 assert!(
917 pushing.contains("A remote may close it first"),
918 "{pushing:?}"
919 );
920 assert!(pushing.contains("Connection reset by peer"), "{pushing:?}");
921 assert!(
922 pushing.contains("Moving a gate entry earlier"),
923 "{pushing:?}"
924 );
925
926 // Once, not every minute.
927 let later = flat(&beat_line(&r, false, B, true));
928 assert!(!later.contains("close it first"), "{later:?}");
929
930 // Never at commit time: nothing is waiting on a socket there.
931 let committing = flat(&beat_line(&r, true, B, false));
932 assert!(!committing.contains("close it first"), "{committing:?}");
933 }
934
935 /// The advice that does NOT appear, and must not come back.
936 ///
937 /// `ServerAliveInterval 60` is the obvious suggestion and it is wrong:
938 /// it was already in force on the machine where this failure was
939 /// diagnosed, and the remote reset the connection regardless. Telling
940 /// every amont user to set it would be confident, actionable and
941 /// useless. This test exists so that a future reader who has the same
942 /// obvious idea meets an argument instead of a blank.
943 #[test]
944 fn the_push_note_does_not_recommend_ssh_keepalive() {
945 let r = row("cargo-test", 60.0);
946 let pushing = flat(&beat_line(&r, true, B, true));
947 assert!(
948 !pushing.contains("ServerAlive"),
949 "keepalive was already on when this failed; recommending it \
950 would be useless advice: {pushing:?}"
951 );
952 assert!(
953 pushing.contains("ssh keepalive does not prevent this"),
954 "say so, rather than leaving the reader to try it: {pushing:?}"
955 );
956 }
957}