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/// Whether the capture mechanism is on at all. `amont.progress false` is the
585/// escape hatch back to raw streaming — one knob, read once.
586pub fn enabled() -> bool {
587 static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
588 *ENABLED.get_or_init(|| crate::config::boolean_or("amont.progress", true))
589}
590
591/// Is anyone watching? True only when stderr is a real terminal that speaks
592/// VT: not piped, not redirected, not `TERM=dumb` — and on Windows only
593/// with `TERM` actually set, because bare conhost may not interpret the
594/// cursor codes the region depends on. This is the paint gate; capture
595/// ([`enabled`]) does not consult it.
596pub fn watching() -> bool {
597 static WATCHING: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
598 *WATCHING.get_or_init(|| {
599 if !std::io::stderr().is_terminal() {
600 return false;
601 }
602 match std::env::var("TERM") {
603 Ok(term) => term != "dumb",
604 Err(_) => !cfg!(windows),
605 }
606 })
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 /// The atomicity contract at the unit level: two threads writing
614 /// interleaved lines into their own slots come out as two contiguous
615 /// buffers, whatever the scheduler did.
616 #[test]
617 fn slots_do_not_share_a_buffer() {
618 let stage = Stage::begin(&["a", "b"]);
619 std::thread::scope(|scope| {
620 for idx in 0..2 {
621 let stage = Arc::clone(&stage);
622 scope.spawn(move || {
623 let _guard = stage.enter(idx);
624 for i in 0..50 {
625 say(&format!("check-{idx} line-{i}"));
626 std::thread::yield_now();
627 }
628 });
629 }
630 });
631 let slots = stage.slots.lock().unwrap();
632 for idx in 0..2 {
633 let text = String::from_utf8(slots[idx].buf.clone()).unwrap();
634 assert_eq!(text.lines().count(), 50);
635 assert!(
636 text.lines()
637 .all(|l| l.starts_with(&format!("check-{idx} "))),
638 "a foreign line landed in slot {idx}"
639 );
640 }
641 }
642
643 /// A thread with no sink prints; its lines never land in anyone's slot.
644 #[test]
645 fn no_sink_means_no_capture() {
646 let stage = Stage::begin(&["a"]);
647 say("goes to stdout, not to a slot");
648 let slots = stage.slots.lock().unwrap();
649 assert!(slots[0].buf.is_empty());
650 }
651
652 /// After finish, late writes are dropped rather than stranded — a child
653 /// reader thread that outlives its check must not corrupt a later block.
654 #[test]
655 fn a_finished_slot_takes_no_more_writes() {
656 let stage = Stage::begin(&["a"]);
657 stage.append_raw(0, b"before\n");
658 stage.finish(0);
659 stage.append_raw(0, b"after\n");
660 let slots = stage.slots.lock().unwrap();
661 assert!(slots[0].buf.is_empty(), "a write landed after finish");
662 }
663
664 /// A repo-derived check name cannot smuggle control bytes onto a live
665 /// terminal: sanitised at begin, once, for every later paint.
666 #[test]
667 fn a_slot_name_is_sanitised_at_begin() {
668 let stage = Stage::begin(&["evil\u{1b}[2Jname\rhere"]);
669 let slots = stage.slots.lock().unwrap();
670 assert!(!slots[0].name.contains('\u{1b}'), "{:?}", slots[0].name);
671 assert!(!slots[0].name.contains('\r'), "{:?}", slots[0].name);
672 }
673
674 /// Region names drop the stage's own prefix — it is the same twelve
675 /// characters on every line.
676 #[test]
677 fn a_slot_name_drops_the_stage_prefix() {
678 let stage = Stage::begin(&["pre-commit-clippy", "pre-push-run-tests", "bare"]);
679 let slots = stage.slots.lock().unwrap();
680 assert_eq!(slots[0].name, "clippy");
681 assert_eq!(slots[1].name, "run-tests");
682 assert_eq!(slots[2].name, "bare");
683 }
684
685 fn row(name: &str, elapsed: f64) -> Row {
686 Row {
687 name: name.into(),
688 elapsed,
689 quiet: 0.0,
690 }
691 }
692
693 const B: Budgets = Budgets {
694 idle: 120,
695 ceiling: 3600,
696 };
697
698 /// The spinner frame comes from the clock: different elapsed, different
699 /// frame; same elapsed, same frame.
700 #[test]
701 fn frames_advance_with_time() {
702 let a = region(&[row("clippy", 0.0)], 80, B);
703 let b = region(&[row("clippy", 0.1)], 80, B);
704 let c = region(&[row("clippy", 1.0)], 80, B);
705 assert_ne!(a.chars().next(), b.chars().next());
706 assert_eq!(a.chars().next(), c.chars().next(), "10 frames per second");
707 }
708
709 /// Names pad to a column so the elapsed figures align — across the
710 /// minute mark too, where the figure changes shape.
711 #[test]
712 fn region_lines_align() {
713 let text = region(&[row("a", 0.0), row("longer-name", 0.0)], 80, B);
714 let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
715 assert_eq!(widths[0], widths[1], "{text:?}");
716 let text = region(&[row("a", 3.2), row("b", 492.0)], 80, B);
717 let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
718 assert_eq!(widths[0], widths[1], "{text:?}");
719 assert!(text.contains("8m12s"), "{text:?}");
720 }
721
722 /// Thirteen running checks paint as twelve lines and one overflow.
723 #[test]
724 fn region_caps_and_counts_the_rest() {
725 let entries: Vec<Row> = (0..13).map(|i| row(&format!("check-{i}"), 0.0)).collect();
726 let text = region(&entries, 80, B);
727 assert_eq!(text.lines().count(), MAX_LINES + 1);
728 assert!(text.ends_with("… and 1 more\n"), "{text:?}");
729 }
730
731 /// A narrow terminal truncates rather than wraps — a wrapped region
732 /// line would break the erase arithmetic.
733 #[test]
734 fn region_respects_width() {
735 let text = region(&[row("a-name-much-longer-than-the-terminal", 0.0)], 20, B);
736 assert!(text.lines().all(|l| l.chars().count() <= 20), "{text:?}");
737 }
738
739 /// No running checks, no region — not even a blank line.
740 #[test]
741 fn an_empty_region_is_empty() {
742 assert_eq!(region(&[], 80, B), "");
743 }
744
745 /// Silence is annotated only once it is news, and names the budget it
746 /// counts toward — a check that just paused between crates says
747 /// nothing extra.
748 #[test]
749 fn a_quiet_check_shows_its_silence_against_the_budget() {
750 let mut r = row("cargo-test", 300.0);
751 r.quiet = 5.0;
752 assert!(!region(&[r.clone()], 80, B).contains("quiet"));
753 r.quiet = 45.0;
754 let text = region(&[r.clone()], 80, B);
755 assert!(text.contains("quiet 45s/2m00s"), "{text:?}");
756 let off = Budgets { idle: 0, ..B };
757 let text = region(&[r], 80, off);
758 assert!(
759 text.contains("quiet 45s") && !text.contains('/'),
760 "{text:?}"
761 );
762 }
763
764 /// The ceiling appears once a check is 80% of the way to it — the cliff,
765 /// shown before the fall — and never for a disabled ceiling.
766 #[test]
767 fn the_ceiling_shows_only_when_it_is_near() {
768 assert!(!region(&[row("cargo-test", 1000.0)], 80, B).contains("/1h00m"));
769 let text = region(&[row("cargo-test", 3000.0)], 80, B);
770 assert!(text.contains("50m00s/1h00m"), "{text:?}");
771 let off = Budgets { ceiling: 0, ..B };
772 assert!(!region(&[row("cargo-test", 3000.0)], 80, off).contains("/"));
773 }
774
775 /// The heartbeat says how long, how quiet, and — the first time — the
776 /// budgets, so a reader at the far end of a pipe can tell "wait" from
777 /// "kill it" without the docs.
778 #[test]
779 fn a_heartbeat_names_the_budgets_once() {
780 let mut r = row("cargo-test", 60.0);
781 r.quiet = 2.0;
782 let first = beat_line(&r, true, B, false);
783 assert!(
784 first.contains("cargo-test still running: 1m00s"),
785 "{first:?}"
786 );
787 assert!(first.contains("last output 2s ago"), "{first:?}");
788 assert!(
789 first.contains("2m00s of silence or 1h00m in total"),
790 "{first:?}"
791 );
792 assert!(first.contains("amont.idleTimeout"), "{first:?}");
793 let later = beat_line(&r, false, B, false);
794 assert!(!later.contains("amont.idleTimeout"), "{later:?}");
795 let off = beat_line(
796 &r,
797 true,
798 Budgets {
799 idle: 0,
800 ceiling: 0,
801 },
802 false,
803 );
804 assert!(off.contains("off of silence or off in total"), "{off:?}");
805 }
806
807 /// The message, with newlines and indentation flattened.
808 ///
809 /// The note is wrapped for a terminal, so a literal substring can fall
810 /// across a line break — asserting on `"may close it first"` failed for
811 /// no better reason than that `may` ended a line. These tests are about
812 /// what the message SAYS; re-wrapping it should not break them.
813 fn flat(line: &str) -> String {
814 line.split_whitespace().collect::<Vec<_>>().join(" ")
815 }
816
817 /// A long PUSH gate is told what it is sitting on; a commit gate is not.
818 ///
819 /// The three negatives matter as much as the positive. Said on every
820 /// beat it would be nagging; said at commit time it would be false —
821 /// there is no connection open — and a future refactor that wires
822 /// `on_push` to a constant would show up here and nowhere else.
823 #[test]
824 fn a_long_push_gate_is_told_what_it_is_sitting_on() {
825 let r = row("cargo-test", 60.0);
826
827 let pushing = flat(&beat_line(&r, true, B, true));
828 assert!(
829 pushing.contains("A remote may close it first"),
830 "{pushing:?}"
831 );
832 assert!(pushing.contains("Connection reset by peer"), "{pushing:?}");
833 assert!(
834 pushing.contains("Moving a gate entry earlier"),
835 "{pushing:?}"
836 );
837
838 // Once, not every minute.
839 let later = flat(&beat_line(&r, false, B, true));
840 assert!(!later.contains("close it first"), "{later:?}");
841
842 // Never at commit time: nothing is waiting on a socket there.
843 let committing = flat(&beat_line(&r, true, B, false));
844 assert!(!committing.contains("close it first"), "{committing:?}");
845 }
846
847 /// The advice that does NOT appear, and must not come back.
848 ///
849 /// `ServerAliveInterval 60` is the obvious suggestion and it is wrong:
850 /// it was already in force on the machine where this failure was
851 /// diagnosed, and the remote reset the connection regardless. Telling
852 /// every amont user to set it would be confident, actionable and
853 /// useless. This test exists so that a future reader who has the same
854 /// obvious idea meets an argument instead of a blank.
855 #[test]
856 fn the_push_note_does_not_recommend_ssh_keepalive() {
857 let r = row("cargo-test", 60.0);
858 let pushing = flat(&beat_line(&r, true, B, true));
859 assert!(
860 !pushing.contains("ServerAlive"),
861 "keepalive was already on when this failed; recommending it \
862 would be useless advice: {pushing:?}"
863 );
864 assert!(
865 pushing.contains("ssh keepalive does not prevent this"),
866 "say so, rather than leaving the reader to try it: {pushing:?}"
867 );
868 }
869}