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