Skip to main content

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}