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    /// The command this check is waiting on, while it runs: its output
74    /// clock and what its CPU is doing — the same object the kill decision
75    /// reads, so the displays can never disagree with it (ADR-0008).
76    activity: Option<Arc<crate::hooks::common::Activity>>,
77    /// Waiting for a host slot since, and how many slots there are
78    /// (ADR-0009).
79    queued: Option<(Instant, u64)>,
80    /// Under quiet: where the tool's output goes instead of `buf` (the whole
81    /// of it to a log, a bounded excerpt in memory). `buf` keeps the check's
82    /// own messages. See [`crate::capture`].
83    capture: Option<crate::capture::Capture>,
84    /// The exit code of the last tool the check ran, for the failure line.
85    runner_exit: Option<i32>,
86    /// The `running <check>, log <path>` line has been printed.
87    announced: bool,
88}
89
90/// A quiet run says where its log is only once it has taken this long, so a
91/// quick pass is exactly one line even under `2>&1`.
92const ANNOUNCE_SECS: u64 = 5;
93
94/// A running stage: the slots, and the one lock every terminal write inside
95/// the stage goes through.
96pub struct Stage {
97    slots: Mutex<Vec<Slot>>,
98    /// Serialises block emission and region repaints; the value is how many
99    /// region lines are currently painted (what an erase must remove).
100    out: Mutex<usize>,
101    /// Painting at all? [`enabled`] && [`watching`], decided once at begin.
102    live: bool,
103    /// Is this the PUSH stage? Read by the heartbeat, which has something to
104    /// say about a long gate there and nothing to say about one at commit
105    /// time — see [`beat_line`]. Derived from the names, which already
106    /// carry the trigger.
107    on_push: bool,
108    /// [`quiet`], decided once at begin: tool output is captured to a log and
109    /// a passing check prints one line.
110    quiet: bool,
111    log_dir: Option<std::path::PathBuf>,
112    /// A check run by name (`amont run <check>`): its pass is one `ok` line.
113    /// Inside a stage a pass prints nothing, like every other success line
114    /// under quiet — the stage's tally speaks for it — and its log stays.
115    named: AtomicBool,
116    stop: AtomicBool,
117}
118
119thread_local! {
120    /// Where [`say`] routes on THIS thread: a stage and a slot index.
121    static SINK: RefCell<Option<(Arc<Stage>, usize)>> = const { RefCell::new(None) };
122}
123
124impl Stage {
125    /// A stage over `names`, in dispatch order. Does nothing visible until
126    /// checks start entering (the region) or finishing (the blocks).
127    pub fn begin(settings: &crate::config::Settings, names: &[&str]) -> Arc<Stage> {
128        let now = Instant::now();
129        let stage = Arc::new(Stage {
130            slots: Mutex::new(
131                names
132                    .iter()
133                    .map(|n| Slot {
134                        // Every name in a stage carries the stage's own
135                        // prefix ("pre-commit-clippy"); the region drops it
136                        // — twelve identical prefixes say nothing.
137                        name: crate::ui::sanitize(
138                            n.strip_prefix("pre-commit-")
139                                .or_else(|| n.strip_prefix("pre-push-"))
140                                .unwrap_or(n),
141                        ),
142                        started: now,
143                        last_output: now,
144                        next_beat: HEARTBEAT_SECS,
145                        buf: Vec::new(),
146                        running: false,
147                        done: false,
148                        activity: None,
149                        queued: None,
150                        capture: None,
151                        runner_exit: None,
152                        announced: false,
153                    })
154                    .collect(),
155            ),
156            out: Mutex::new(0),
157            live: enabled(settings) && watching(),
158            // The names arrive fully qualified and the loop above has
159            // already had to strip the trigger to display them, so the
160            // stage can answer this without dispatch passing anything in.
161            on_push: names.iter().any(|n| n.starts_with("pre-push-")),
162            quiet: quiet(settings),
163            named: AtomicBool::new(false),
164            log_dir: if quiet(settings) {
165                crate::capture::log_dir()
166            } else {
167                None
168            },
169            stop: AtomicBool::new(false),
170        });
171        if stage.live {
172            // The ticker holds a Weak: the stage dropping is what ends it,
173            // so a paint can never outlive the region's owner.
174            let weak = Arc::downgrade(&stage);
175            let own = settings.for_thread();
176            let _ = std::thread::Builder::new()
177                .name("amont-live".into())
178                .spawn(move || tick(own, weak));
179        } else if enabled(settings) || stage.quiet {
180            // Nobody is watching a terminal — an agent, CI, a pipe — and a
181            // captured check shows nothing until it finishes. The heartbeat
182            // is the one line a minute that says it is alive, which is the
183            // difference between "wait" and "kill it" for whoever is on the
184            // other end of the pipe.
185            let weak = Arc::downgrade(&stage);
186            let own = settings.for_thread();
187            let _ = std::thread::Builder::new()
188                .name("amont-heartbeat".into())
189                .spawn(move || heartbeat(own, weak));
190        }
191        stage
192    }
193
194    /// Route this thread's [`say`] calls into slot `idx` until the guard
195    /// drops. Installed by the dispatcher around each `check.run`. Also
196    /// starts the slot's clock and puts it in the region.
197    pub fn enter(self: &Arc<Stage>, idx: usize) -> SinkGuard {
198        {
199            let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
200            if let Some(slot) = slots.get_mut(idx) {
201                slot.running = true;
202                slot.started = Instant::now();
203                slot.last_output = slot.started;
204                slot.next_beat = HEARTBEAT_SECS;
205                if self.quiet {
206                    slot.capture = Some(crate::capture::Capture::open(
207                        self.log_dir.clone(),
208                        &slot.name,
209                    ));
210                }
211            }
212        }
213        SINK.with(|s| *s.borrow_mut() = Some((Arc::clone(self), idx)));
214        SinkGuard
215    }
216
217    /// Slot `idx` is waiting for a host slot (`Some`), or no longer is.
218    pub fn queue(&self, idx: usize, since: Option<(Instant, u64)>) {
219        let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
220        if let Some(slot) = slots.get_mut(idx) {
221            slot.queued = since;
222        }
223    }
224
225    /// Show slot `idx`'s spawned command in the displays while the returned
226    /// guard lives. A check that runs several commands in turn attaches each
227    /// one; between them the slot falls back to its own output clock.
228    pub fn attach(
229        self: &Arc<Stage>,
230        idx: usize,
231        activity: Arc<crate::hooks::common::Activity>,
232    ) -> AttachGuard {
233        let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
234        if let Some(slot) = slots.get_mut(idx) {
235            slot.activity = Some(activity);
236        }
237        AttachGuard {
238            stage: Arc::clone(self),
239            idx,
240        }
241    }
242
243    /// Append raw bytes (a captured child's output) to slot `idx`.
244    pub fn append_raw(&self, idx: usize, bytes: &[u8]) {
245        let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
246        if let Some(slot) = slots.get_mut(idx) {
247            if !slot.done {
248                match slot.capture.as_mut() {
249                    Some(capture) => capture.take(bytes),
250                    None => slot.buf.extend_from_slice(bytes),
251                }
252                slot.last_output = Instant::now();
253            }
254        }
255    }
256
257    /// This stage is one check run by name: its pass prints an `ok` line.
258    pub fn named(&self) {
259        self.named.store(true, Ordering::Relaxed);
260    }
261
262    /// The exit code of the tool slot `idx` just ran (`None`: killed).
263    pub fn runner_exit(&self, idx: usize, code: Option<i32>) {
264        let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
265        if let Some(slot) = slots.get_mut(idx) {
266            slot.runner_exit = code;
267        }
268    }
269
270    fn append_line(&self, idx: usize, line: &str) {
271        let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
272        if let Some(slot) = slots.get_mut(idx) {
273            if !slot.done {
274                slot.buf.extend_from_slice(line.as_bytes());
275                slot.buf.push(b'\n');
276                slot.last_output = Instant::now();
277            }
278        }
279    }
280
281    /// The check is over: emit everything it said as ONE contiguous write,
282    /// with the region lifted out of the way first and repainted after —
283    /// blocks pile up above, spinners stay below.
284    ///
285    /// Called by the dispatcher after `check.run` returns (still on the
286    /// check's thread, so a torn-down thread cannot strand a buffer — the
287    /// same `catch_unwind` that feeds the dead-check outcome runs first).
288    ///
289    /// `outcome` is the check's, when it returned one; `None` is a check
290    /// whose thread died, which has not passed. Only a quiet slot reads it.
291    pub fn finish(
292        &self,
293        settings: &crate::config::Settings,
294        idx: usize,
295        outcome: Option<crate::check::Outcome>,
296    ) {
297        let block = {
298            let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
299            let Some(slot) = slots.get_mut(idx) else {
300                return;
301            };
302            slot.done = true;
303            slot.running = false;
304            let said = std::mem::take(&mut slot.buf);
305            match slot.capture.take() {
306                None => QuietOut::plain(said),
307                Some(capture) => quiet_block(QuietEnd {
308                    name: &slot.name,
309                    secs: slot.started.elapsed().as_secs_f64(),
310                    outcome,
311                    said,
312                    capture,
313                    runner_exit: slot.runner_exit,
314                    ci: in_ci(),
315                    ok_line: self.named.load(Ordering::Relaxed),
316                }),
317            }
318        };
319        if block.is_empty() && !self.live {
320            return;
321        }
322        let mut drawn = self.out.lock().unwrap_or_else(|p| p.into_inner());
323        if !block.is_empty() {
324            if *drawn > 0 {
325                let mut err = std::io::stderr().lock();
326                let _ = write!(err, "\x1b[{}A\x1b[J", *drawn);
327                let _ = err.flush();
328                *drawn = 0;
329            }
330            let stdout = std::io::stdout();
331            let mut handle = stdout.lock();
332            block.write(&mut handle);
333            let _ = handle.flush();
334        }
335        self.repaint(settings, &mut drawn);
336    }
337
338    /// Erase and redraw the region in one stderr write. Lock order is
339    /// `out` → `slots`, everywhere — never the reverse.
340    fn repaint(&self, settings: &crate::config::Settings, drawn: &mut usize) {
341        if !self.live {
342            return;
343        }
344        let entries: Vec<Row> = {
345            let slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
346            let now = Instant::now();
347            slots
348                .iter()
349                .filter(|s| s.running && !s.done)
350                .map(|s| row_of(s, now, now.duration_since(s.started).as_secs_f64()))
351                .collect()
352        };
353        let text = region(&entries, term_width(), budgets(settings));
354        let mut paint = String::new();
355        if *drawn > 0 {
356            paint.push_str(&format!("\x1b[{}A\x1b[J", *drawn));
357        }
358        paint.push_str(&text);
359        if paint.is_empty() {
360            return;
361        }
362        let mut err = std::io::stderr().lock();
363        let _ = err.write_all(paint.as_bytes());
364        let _ = err.flush();
365        *drawn = text.matches('\n').count();
366    }
367}
368
369impl Drop for Stage {
370    /// The stage's end erases whatever the region still shows — a Block
371    /// verdict, a panic on the dispatcher path, anything: no spinner junk
372    /// above the roll-up. (`get_mut`: dropping proves no other thread holds
373    /// the stage, so the locks are free.)
374    fn drop(&mut self) {
375        self.stop.store(true, Ordering::Relaxed);
376        if !self.live {
377            return;
378        }
379        let drawn = self.out.get_mut().unwrap_or_else(|p| p.into_inner());
380        if *drawn > 0 {
381            let mut err = std::io::stderr().lock();
382            let _ = write!(err, "\x1b[{}A\x1b[J", *drawn);
383            let _ = err.flush();
384            *drawn = 0;
385        }
386    }
387}
388
389/// The ticker: repaint every 80ms until the stage drops or tells it to
390/// stop. Holds only a `Weak`, so it can never keep a finished stage alive.
391/// Owns its `Settings` (see [`crate::config::Settings::for_thread`]): a
392/// spawned thread is `'static`, and the budgets must be read lazily, not
393/// pre-resolved at the spawn.
394fn tick(settings: crate::config::Settings, weak: Weak<Stage>) {
395    loop {
396        std::thread::sleep(std::time::Duration::from_millis(80));
397        let Some(stage) = weak.upgrade() else { return };
398        if stage.stop.load(Ordering::Relaxed) {
399            return;
400        }
401        let mut drawn = stage.out.lock().unwrap_or_else(|p| p.into_inner());
402        stage.repaint(&settings, &mut drawn);
403    }
404}
405
406/// One running check, as the region and the heartbeat see it.
407#[derive(Debug, Clone)]
408pub struct Row {
409    pub name: String,
410    /// Seconds since the check entered.
411    pub elapsed: f64,
412    /// Seconds since it last wrote anything.
413    pub quiet: f64,
414    /// Seconds it has been silent AND idle on CPU — what the silence budget
415    /// is judged against. Equal to `quiet` when CPU is not sampled.
416    pub still: f64,
417    pub cpu: RowCpu,
418    /// The declared lock wait it is in, and for how many seconds
419    /// (ADR-0009): shown instead of the silence countdown, which does not
420    /// run meanwhile.
421    pub wait: Option<(crate::hooks::wait::WaitKind, f64)>,
422    /// The silence budget the kill decision is applying, stretched by the
423    /// host's load, with the load and the factor; `None` while it is the
424    /// configured one (ADR-0009).
425    pub load: Option<(u64, crate::load::Load, u32)>,
426    /// Waiting for a host slot: for how many seconds, out of how many
427    /// slots.
428    pub queued: Option<(f64, u64)>,
429}
430
431/// What a running check's CPU is doing, as far as the displays may say.
432#[derive(Debug, Clone, Copy, PartialEq, Eq)]
433pub enum RowCpu {
434    /// No spawned command is attached (an in-process check, or between two
435    /// commands): nothing to say.
436    None,
437    /// A command is attached but its CPU is not sampled here
438    /// (`amont.idleCpuCredit false`, no silence budget, or the platform):
439    /// silence alone counts.
440    NotSampled,
441    /// Sampled, but nothing fresh to report (not quiet long enough yet, or
442    /// the last snapshot was incomplete).
443    Unmeasured,
444    /// Measurably working, at this many thousandths of a core.
445    Busy(u32),
446    /// Measured under the busy threshold.
447    Idle,
448}
449
450/// A slot as a [`Row`], reading its attached command's clocks when there is
451/// one. The quiet figure is the more recent of the slot's own lines and the
452/// command's bytes (a captured command writes to one and not the other).
453fn row_of(s: &Slot, now: Instant, elapsed: f64) -> Row {
454    use crate::hooks::common::{CpuState, BUSY_MILLI_CORES};
455    let slot_quiet = now.duration_since(s.last_output).as_secs_f64();
456    let (quiet, still, cpu, wait, load) = match &s.activity {
457        None => (slot_quiet, slot_quiet, RowCpu::None, None, None),
458        Some(a) => {
459            let quiet = slot_quiet.min(a.quiet_for().as_secs_f64());
460            let still = quiet.min(a.still_for().as_secs_f64());
461            let cpu = match (a.cpu_state(), a.fresh_rate()) {
462                (CpuState::Off, _) => RowCpu::NotSampled,
463                (_, Some(r)) if r >= BUSY_MILLI_CORES => RowCpu::Busy(r),
464                (_, Some(_)) => RowCpu::Idle,
465                (_, None) => RowCpu::Unmeasured,
466            };
467            let wait = a.waiting().map(|(k, d)| (k, d.as_secs_f64()));
468            (quiet, still, cpu, wait, a.load_scale())
469        }
470    };
471    Row {
472        name: s.name.clone(),
473        elapsed,
474        quiet,
475        still,
476        cpu,
477        wait,
478        load,
479        queued: s
480            .queued
481            .map(|(since, n)| (now.duration_since(since).as_secs_f64(), n)),
482    }
483}
484
485impl Row {
486    /// The silence budget this row counts toward: the load-stretched one
487    /// when the host stretched it, else the configured one.
488    fn applied_idle(&self, budgets: Budgets) -> u64 {
489        match self.load {
490            Some((applied, _, _)) if applied > 0 => applied,
491            _ => budgets.idle,
492        }
493    }
494}
495
496/// The clocks, as the region annotates them, in seconds: `idle` and
497/// `ceiling` with `0` for off; `lock_wait` with `0` for "until the
498/// ceiling".
499#[derive(Debug, Clone, Copy)]
500pub struct Budgets {
501    pub idle: u64,
502    pub ceiling: u64,
503    pub lock_wait: u64,
504    /// The extended silence budget, `idle × amont.idleLoadScale`: what a
505    /// check answers to while its CPU cannot be measured.
506    pub extended: u64,
507}
508
509fn budgets(settings: &crate::config::Settings) -> Budgets {
510    let idle = crate::hooks::common::idle_timeout(settings);
511    Budgets {
512        idle,
513        extended: idle.saturating_mul(crate::hooks::common::idle_load_scale(settings)),
514        ceiling: crate::hooks::common::check_timeout(settings),
515        lock_wait: match crate::hooks::common::lock_wait(settings) {
516            crate::hooks::common::LockWait::Secs(s) => s,
517            crate::hooks::common::LockWait::UntilCeiling => 0,
518        },
519    }
520}
521
522/// How long a check must be quiet before the region says so. A test suite
523/// pauses this long between crates without anything being wrong; past it,
524/// the reader wants to know the silence is being counted.
525const QUIET_NOTE_SECS: f64 = 30.0;
526
527/// The non-tty heartbeat's period: one line a minute per running check.
528const HEARTBEAT_SECS: u64 = 60;
529
530/// Elapsed time in a fixed six-column figure: `  3.2s` under a minute,
531/// `8m12s` and `1h02m` above, so the column stays aligned as the suite
532/// crosses the minute.
533fn elapsed_column(secs: f64) -> String {
534    if secs < 60.0 {
535        format!("{secs:>5.1}s")
536    } else {
537        format!("{:>6}", crate::hooks::common::human_secs(secs as u64))
538    }
539}
540
541/// The region's text: one `⠹ name  12.3s` line per running check, capped at
542/// [`MAX_LINES`] plus a `… and N more` overflow line. Pure — the ticker is
543/// a thin shell around this, and the tests drive it directly.
544///
545/// Two annotations, each only when it carries news: `· quiet 45s/2m` once
546/// a check has been silent past [`QUIET_NOTE_SECS`] (with the silence
547/// budget it is counting toward, when there is one), and `· 48m/60m` once
548/// elapsed passes 80% of the ceiling — the cliff, shown before the fall.
549fn region(entries: &[Row], width: usize, budgets: Budgets) -> String {
550    if entries.is_empty() {
551        return String::new();
552    }
553    let pad = entries
554        .iter()
555        .take(MAX_LINES)
556        .map(|r| r.name.chars().count())
557        .max()
558        .unwrap_or(0);
559    let mut out = String::new();
560    for row in entries.iter().take(MAX_LINES) {
561        let frame = FRAMES[((row.elapsed * 10.0) as usize) % FRAMES.len()];
562        let name = &row.name;
563        let mut line = format!("{frame} {name:<pad$} {}", elapsed_column(row.elapsed));
564        if let Some((secs, n)) = row.queued {
565            line.push_str(&format!(
566                " · queued {} (slots {n}/{n})",
567                crate::hooks::common::human_secs(secs as u64)
568            ));
569        } else if let Some((kind, waited)) = row.wait {
570            // A declared wait: no silence countdown runs, so the row says
571            // what is being waited for and against which budget.
572            line.push_str(&format!(
573                " · {} {}",
574                kind.short(),
575                crate::hooks::common::human_secs(waited as u64)
576            ));
577            if budgets.lock_wait > 0 {
578                line.push_str(&format!(
579                    "/{}",
580                    crate::hooks::common::human_secs(budgets.lock_wait)
581                ));
582            }
583        } else if row.quiet >= QUIET_NOTE_SECS {
584            let quiet = crate::hooks::common::human_secs(row.quiet as u64);
585            match row.cpu {
586                // Working: no countdown — no kill is coming — just how hard.
587                RowCpu::Busy(m) => line.push_str(&format!(
588                    " · quiet {quiet} · {}",
589                    crate::hooks::common::cores(m)
590                )),
591                _ if budgets.idle == 0 => line.push_str(&format!(" · quiet {quiet}")),
592                // The countdown counts what the kill decision counts: the
593                // still-time, which only differs from the silence once CPU
594                // work has pushed it back.
595                // Unmeasured: the extended budget is the one that counts,
596                // and the row says why the figure is not the usual one.
597                RowCpu::Unmeasured => line.push_str(&format!(
598                    " · quiet {quiet}/{} (CPU unmeasured)",
599                    crate::hooks::common::human_secs(budgets.extended.max(budgets.idle))
600                )),
601                RowCpu::Idle if row.quiet - row.still >= 1.0 => line.push_str(&format!(
602                    " · quiet {quiet} · idle {}/{}",
603                    crate::hooks::common::human_secs(row.still as u64),
604                    crate::hooks::common::human_secs(row.applied_idle(budgets))
605                )),
606                _ => line.push_str(&format!(
607                    " · quiet {quiet}/{}",
608                    crate::hooks::common::human_secs(row.applied_idle(budgets))
609                )),
610            }
611            if let (Some((_, _, factor)), false) = (row.load, row.cpu == RowCpu::Unmeasured) {
612                line.push_str(&format!(" (load {})", crate::load::factor_text(factor)));
613            }
614        }
615        if budgets.ceiling > 0 && row.elapsed >= 0.8 * budgets.ceiling as f64 {
616            line.push_str(&format!(
617                " · {}/{}",
618                crate::hooks::common::human_secs(row.elapsed as u64),
619                crate::hooks::common::human_secs(budgets.ceiling)
620            ));
621        }
622        if line.chars().count() > width {
623            out.extend(line.chars().take(width));
624        } else {
625            out.push_str(&line);
626        }
627        out.push('\n');
628    }
629    if entries.len() > MAX_LINES {
630        out.push_str(&format!("… and {} more\n", entries.len() - MAX_LINES));
631    }
632    out
633}
634
635/// The heartbeat: once a minute, for each check still running, one plain
636/// line on stderr — elapsed, and how long since it last said anything.
637/// Not a region: nothing is erased or repainted, because nobody is looking
638/// at a cursor; whoever reads this reads a log.
639///
640/// The first beat for a check also names the two budgets, once, so the
641/// reader can tell how far it is from being killed without opening the
642/// docs. Written under the same `out` lock as the blocks, so a beat never
643/// lands inside one.
644/// Owns its `Settings` for the same reason [`tick`] does.
645/// What a quiet slot knows when its check ends.
646struct QuietEnd<'a> {
647    name: &'a str,
648    secs: f64,
649    outcome: Option<crate::check::Outcome>,
650    /// The check's own messages, as today.
651    said: Vec<u8>,
652    capture: crate::capture::Capture,
653    runner_exit: Option<i32>,
654    ci: bool,
655    /// Print `ok <check> …` on a pass (a check run by name).
656    ok_line: bool,
657}
658
659/// `CI` set to anything but empty, `false` or `0`. Under CI a failure prints
660/// its whole log: the log file goes away with the runner.
661fn in_ci() -> bool {
662    std::env::var("CI").is_ok_and(|v| !v.is_empty() && v != "false" && v != "0")
663}
664
665/// A quiet slot's block: one line for a pass; for anything else the check's
666/// own messages, then each failure block of the tool's output in full and
667/// the log's path. Pure but for the log file it closes and renames.
668fn quiet_block(mut end: QuietEnd) -> QuietOut {
669    let stored = end.capture.finish();
670    let failed = is_failure(end.outcome);
671    let passed = end.outcome == Some(crate::check::Outcome::Passed);
672    if end.capture.seen == 0 {
673        // No tool output: a check that runs no tool (ban-terms, secrets), or
674        // one the push stage skipped before it ran. Exactly what it printed
675        // before quiet captured anything — under quiet, nothing on a pass —
676        // and no empty log left behind. A tool that ran and printed nothing
677        // on a failure says so.
678        if let Some(p) = stored.path() {
679            let _ = std::fs::remove_file(p);
680        }
681        let mut head = end.said;
682        if failed && end.runner_exit.is_some() {
683            head.extend_from_slice(b"(no output)\n");
684            if let Some(code) = end.runner_exit.filter(|c| *c != 0) {
685                head.extend_from_slice(
686                    format!("{} failed, runner exit {code}\n", end.name).as_bytes(),
687                );
688            }
689        }
690        return QuietOut::plain(head);
691    }
692    if passed && !end.ok_line {
693        // In a stage: silent, like every passing check under quiet; the log
694        // stays on disk for whoever wants it.
695        return QuietOut::plain(end.said);
696    }
697    let path = stored.path().map(|p| p.display().to_string());
698    if passed {
699        let mut out = format!("ok {} {:.1}s", end.name, end.secs);
700        if let (crate::capture::Stored::Saved(_), Some(p)) = (&stored, &path) {
701            out.push_str(&format!(" log={p}"));
702        }
703        out.push('\n');
704        if let Some(note) = stored.note() {
705            out.push_str(&note);
706            out.push('\n');
707        }
708        return QuietOut::plain(out.into_bytes());
709    }
710    let excerpt = end.capture.acc.finish(path.as_deref().unwrap_or("-"));
711    let mut tail = String::new();
712    if failed {
713        if let Some(code) = end.runner_exit.filter(|c| *c != 0) {
714            tail.push_str(&format!("{} failed, runner exit {code}\n", end.name));
715        }
716    }
717    if let Some(p) = &path {
718        tail.push_str(&format!("full log: {p}\n"));
719    }
720    if let Some(note) = stored.note() {
721        tail.push_str(&note);
722        tail.push('\n');
723    }
724    // Under CI a failure prints its whole log, copied from the file when the
725    // block is written: the file goes away with the runner. The excerpt is
726    // what prints if the copy cannot be made.
727    match (&stored, end.ci && failed) {
728        (crate::capture::Stored::Saved(p), true) => QuietOut {
729            head: end.said,
730            stream: Some((p.clone(), excerpt)),
731            tail: tail.into_bytes(),
732        },
733        _ => {
734            let mut head = end.said;
735            head.extend_from_slice(excerpt.as_bytes());
736            QuietOut {
737                head,
738                stream: None,
739                tail: tail.into_bytes(),
740            }
741        }
742    }
743}
744
745/// A finished slot's block: `head`, then a log file copied out whole (with
746/// the excerpt to print if the copy fails), then `tail`.
747struct QuietOut {
748    head: Vec<u8>,
749    stream: Option<(std::path::PathBuf, String)>,
750    tail: Vec<u8>,
751}
752
753impl QuietOut {
754    fn plain(head: Vec<u8>) -> QuietOut {
755        QuietOut {
756            head,
757            stream: None,
758            tail: Vec::new(),
759        }
760    }
761
762    fn is_empty(&self) -> bool {
763        self.head.is_empty() && self.stream.is_none() && self.tail.is_empty()
764    }
765
766    fn write(self, to: &mut impl Write) {
767        let _ = to.write_all(&self.head);
768        if let Some((path, excerpt)) = self.stream {
769            let copied = std::fs::File::open(&path).and_then(|mut f| std::io::copy(&mut f, to));
770            if let Err(e) = copied {
771                let _ = to.write_all(excerpt.as_bytes());
772                let _ = writeln!(to, "log not read: {e}");
773            }
774        }
775        let _ = to.write_all(&self.tail);
776    }
777}
778
779/// Whether an outcome is a failure for the quiet block: the failure line
780/// and, under CI, the whole log. A check whose thread died (`None`) has not
781/// passed. Every variant named, so a new one is a compile error here.
782fn is_failure(outcome: Option<crate::check::Outcome>) -> bool {
783    use crate::check::Outcome;
784    match outcome {
785        None => true,
786        Some(Outcome::Failed) => true,
787        Some(
788            Outcome::Passed
789            | Outcome::Warned
790            | Outcome::Fixed
791            | Outcome::Unavailable
792            | Outcome::Inert,
793        ) => false,
794    }
795}
796
797fn heartbeat(settings: crate::config::Settings, weak: Weak<Stage>) {
798    loop {
799        std::thread::sleep(std::time::Duration::from_secs(1));
800        let Some(stage) = weak.upgrade() else { return };
801        if stage.stop.load(Ordering::Relaxed) {
802            return;
803        }
804        let mut announce: Vec<String> = Vec::new();
805        let due: Vec<(Row, bool)> = {
806            let mut slots = stage.slots.lock().unwrap_or_else(|p| p.into_inner());
807            let now = Instant::now();
808            let mut due = Vec::new();
809            for s in slots.iter_mut().filter(|s| s.running && !s.done) {
810                let elapsed = now.duration_since(s.started).as_secs();
811                // Only a log the tool has written to: a check that prints
812                // nothing keeps no log, and the line would point at nothing.
813                if !s.announced && elapsed >= ANNOUNCE_SECS {
814                    if let Some(c) = s.capture.as_ref().filter(|c| c.writing() && c.seen > 0) {
815                        s.announced = true;
816                        announce.push(format!("running {}, log {}\n", s.name, c.tmp.display()));
817                    }
818                }
819                if elapsed >= s.next_beat {
820                    let first = s.next_beat == HEARTBEAT_SECS;
821                    s.next_beat += HEARTBEAT_SECS;
822                    due.push((row_of(s, now, elapsed as f64), first));
823                }
824            }
825            due
826        };
827        if due.is_empty() && announce.is_empty() {
828            continue;
829        }
830        let text: String = announce.concat()
831            + &due
832                .iter()
833                .map(|(row, first)| beat_line(row, *first, budgets(&settings), stage.on_push))
834                .collect::<String>();
835        let _guard = stage.out.lock().unwrap_or_else(|p| p.into_inner());
836        let mut err = std::io::stderr().lock();
837        let _ = err.write_all(text.as_bytes());
838        let _ = err.flush();
839    }
840}
841
842/// One heartbeat line. Pure, for the tests.
843///
844/// On the FIRST beat of a PUSH gate it also names something no other part of
845/// the system is placed to explain. `git push` opens its connection to the
846/// remote, reads the remote refs — which is where the `pre-push` hook's own
847/// stdin comes from — and only then calls the hook. The connection is
848/// therefore already open and goes idle for exactly as long as the gate
849/// runs, and a remote may close it before the gate finishes. git then
850/// reports `Connection reset by peer`, which reads as a network fault and
851/// says nothing about the seven minutes that caused it.
852///
853/// The note does NOT recommend ssh keepalive, and that omission is
854/// deliberate: `ServerAliveInterval 60` was already in force on the machine
855/// where this was diagnosed, and GitHub reset the connection anyway.
856/// Whatever the remote is measuring, it is not packets. Recommending it
857/// would be a confident instruction to change a setting that is probably
858/// already on and cannot help, so the note says so and points at the thing
859/// that does work.
860///
861/// Only on a first beat, so it is said once; only on a push, so a commit
862/// gate never hears it. A first beat is a check that has already run a full
863/// minute, which is the population at risk — no threshold to invent.
864fn beat_line(row: &Row, first: bool, budgets: Budgets, on_push: bool) -> String {
865    use crate::hooks::common::human_secs;
866    // The prefix is byte-for-byte what it always was: log readers grep it.
867    // What CPU sampling adds goes after it.
868    let mut line = format!(
869        "  … {} still running: {}, last output {} ago",
870        row.name,
871        human_secs(row.elapsed as u64),
872        human_secs(row.quiet as u64)
873    );
874    if let Some((secs, n)) = row.queued {
875        line.push_str(&format!(
876            ", queued {} for a host slot (amont.hostSlots {n})",
877            human_secs(secs as u64)
878        ));
879    } else if let Some((kind, waited)) = row.wait {
880        line.push_str(&format!(
881            ", waiting for {} {}",
882            kind.describe(),
883            human_secs(waited as u64)
884        ));
885        match budgets.lock_wait {
886            0 => line.push_str(" (amont.lockWait 0: until the ceiling)"),
887            s => line.push_str(&format!(" (amont.lockWait {})", human_secs(s))),
888        }
889    } else {
890        match row.cpu {
891            RowCpu::Busy(m) => line.push_str(&format!(", busy {}", crate::hooks::common::cores(m))),
892            RowCpu::Idle => line.push_str(&format!(", CPU idle {}", human_secs(row.still as u64))),
893            RowCpu::Unmeasured if budgets.idle > 0 => line.push_str(&format!(
894                ", CPU unmeasured — extended budget {}",
895                human_secs(budgets.extended.max(budgets.idle))
896            )),
897            RowCpu::Unmeasured => line.push_str(", CPU unmeasured"),
898            RowCpu::None | RowCpu::NotSampled => {}
899        }
900        if let (Some((applied, load, factor)), false) = (row.load, row.cpu == RowCpu::Unmeasured) {
901            line.push_str(&format!(
902                ", budget {} (load avg {} on {} cores, {} — amont.idleLoadScale)",
903                human_secs(applied),
904                load.avg1_text(),
905                load.cores,
906                crate::load::factor_text(factor)
907            ));
908        }
909    }
910    if first {
911        let idle = match row.applied_idle(budgets) {
912            0 => "off".to_string(),
913            s => human_secs(s),
914        };
915        let ceiling = match budgets.ceiling {
916            0 => "off".to_string(),
917            s => human_secs(s),
918        };
919        match row.cpu {
920            // In a declared wait the silence clock is not running: the rule
921            // in force is the wait's own budget.
922            _ if row.wait.is_some() => line.push_str(&format!(
923                " (killed after {} in the wait, or {ceiling} in total — amont.lockWait / \
924                 amont.timeout)",
925                match budgets.lock_wait {
926                    0 => ceiling.clone(),
927                    s => human_secs(s),
928                }
929            )),
930            // The budget that applies to THIS check, not the configured
931            // one: while CPU is unmeasured that is the extended budget.
932            RowCpu::Unmeasured if budgets.idle > 0 => line.push_str(&format!(
933                " (killed after {} with no output while its CPU is unmeasured, or {ceiling} \
934                 in total — amont.idleTimeout × amont.idleLoadScale / amont.timeout)",
935                human_secs(budgets.extended.max(budgets.idle))
936            )),
937            RowCpu::Busy(_) | RowCpu::Idle if budgets.idle > 0 => line.push_str(&format!(
938                " (killed after {idle} with no output and under 0.1 core of CPU, or \
939                 {ceiling} in total — amont.idleTimeout / amont.timeout)"
940            )),
941            _ => line.push_str(&format!(
942                " (killed after {idle} of silence or {ceiling} in total — amont.idleTimeout / amont.timeout)"
943            )),
944        }
945        if row.cpu == RowCpu::NotSampled && budgets.idle > 0 {
946            line.push_str("; CPU not sampled here, silence alone counts");
947        }
948        if on_push {
949            // `concat!`, not a `\`-continued literal: a continuation keeps
950            // the next line's indentation, which turns the message into runs
951            // of spaces. Each line is its own literal and the newlines are
952            // written down, so what is here is what a reader sees.
953            line.push_str(concat!(
954                "\n    git opened its connection to the remote before calling this",
955                "\n    gate, and it stays idle until the gate finishes. A remote may",
956                "\n    close it first — GitHub does — and the push then fails with",
957                "\n    \"Connection reset by peer\", naming the network rather than the",
958                "\n    wait. ssh keepalive does not prevent this.",
959                "\n    Declaring this check at pre-commit moves it off the push path —",
960                "\n    see \"Moving a gate entry earlier\" in the docs.",
961            ));
962        }
963    }
964    line.push('\n');
965    line
966}
967
968/// `$COLUMNS` when it is exported and sane, else a conservative 80 — the
969/// region's lines are short and an ioctl is not worth its portability. 80,
970/// not wider: shells rarely export `COLUMNS`, and a region line longer than
971/// the real terminal wraps, which breaks the erase arithmetic.
972///
973/// `pub` is now wider than it needs to be — the out-of-crate caller that
974/// justified it, `amont-agent`, is its own project and carries its own copy.
975/// Left public rather than narrowed in the same change that removed it.
976pub fn term_width() -> usize {
977    std::env::var("COLUMNS")
978        .ok()
979        .and_then(|c| c.parse::<usize>().ok())
980        .filter(|w| *w >= 20)
981        .unwrap_or(80)
982}
983
984/// Emits slot `idx`'s block when dropped — however the check's closure
985/// exits, a panic included: the partial output of a check that died still
986/// reaches the reader, above the dead-check verdict the runner fills in.
987pub struct FinishOnDrop<'a> {
988    stage: &'a Stage,
989    idx: usize,
990    /// The check's outcome once it has one; `None` when its thread died.
991    outcome: std::cell::Cell<Option<crate::check::Outcome>>,
992    /// Carried, because `Drop` takes no arguments and the finish paint
993    /// needs the budgets. Same lifetime as the stage it belongs to.
994    settings: &'a crate::config::Settings,
995}
996
997impl<'a> FinishOnDrop<'a> {
998    pub fn new(
999        settings: &'a crate::config::Settings,
1000        stage: &'a Stage,
1001        idx: usize,
1002    ) -> FinishOnDrop<'a> {
1003        FinishOnDrop {
1004            stage,
1005            idx,
1006            outcome: std::cell::Cell::new(None),
1007            settings,
1008        }
1009    }
1010
1011    /// Record what the check returned, for the quiet block.
1012    pub fn outcome(&self, outcome: crate::check::Outcome) {
1013        self.outcome.set(Some(outcome));
1014    }
1015}
1016
1017impl Drop for FinishOnDrop<'_> {
1018    fn drop(&mut self) {
1019        self.stage
1020            .finish(self.settings, self.idx, self.outcome.get());
1021    }
1022}
1023
1024/// Uninstalls the thread's sink on drop, whatever path the check took out.
1025pub struct SinkGuard;
1026
1027impl Drop for SinkGuard {
1028    fn drop(&mut self) {
1029        SINK.with(|s| *s.borrow_mut() = None);
1030    }
1031}
1032
1033/// Detaches a command from its slot's displays when dropped. See
1034/// [`Stage::attach`].
1035pub struct AttachGuard {
1036    stage: Arc<Stage>,
1037    idx: usize,
1038}
1039
1040impl Drop for AttachGuard {
1041    fn drop(&mut self) {
1042        let mut slots = self.stage.slots.lock().unwrap_or_else(|p| p.into_inner());
1043        if let Some(slot) = slots.get_mut(self.idx) {
1044            slot.activity = None;
1045        }
1046    }
1047}
1048
1049/// The sink installed on THIS thread, if any — how a child-capture helper on
1050/// the check's own thread learns where the reader threads should append.
1051pub fn current_sink() -> Option<(Arc<Stage>, usize)> {
1052    SINK.with(|s| s.borrow().clone())
1053}
1054
1055/// One line of check output, wherever it should go.
1056///
1057/// THE funnel: `common::ok/fail/warn` call this, so a check's helper prints
1058/// land in its slot during a stage and on stdout everywhere else. `line` is
1059/// taken without a trailing newline, exactly like `println!`.
1060pub fn say(line: &str) {
1061    let routed = SINK.with(|s| {
1062        s.borrow().as_ref().map(|(stage, idx)| {
1063            stage.append_line(*idx, line);
1064        })
1065    });
1066    if routed.is_none() {
1067        println!("{line}");
1068    }
1069}
1070
1071/// `println!`, stage-aware: formats and routes through [`say`]. What every
1072/// direct print inside a CHECK BODY becomes — a line printed raw from a
1073/// check thread bypasses the slot and interleaves, which is the bug this
1074/// module exists to close.
1075#[macro_export]
1076macro_rules! say {
1077    ($($arg:tt)*) => {
1078        $crate::live::say(&format!($($arg)*))
1079    };
1080}
1081
1082/// Should a check's SUCCESS line be swallowed?
1083///
1084/// A hook that passes says one line per check, and on a clean run that is the
1085/// entire output: fourteen lines to say nothing happened. At a terminal those
1086/// lines are the reassurance that the gate ran. Captured — an agent's tool
1087/// result, a CI log — they are re-read on every later turn of the session and
1088/// say no more the tenth time than the first.
1089///
1090/// So the setting names WHO is reading, not how loud to be:
1091///
1092/// - `auto` (default) — quiet when nobody is watching, verbose at a terminal.
1093/// - `never` — every check says it passed, whoever is reading.
1094/// - `always` — quiet everywhere.
1095///
1096/// `auto` is the default because the reader it costs nothing is the one at a
1097/// terminal: `watching()` is true there, so a person sees exactly what they
1098/// saw before. The reader it saves is the one who cannot skim — a captured
1099/// log, an agent's tool result — and that reader was paying for fourteen
1100/// lines of nothing on every turn of a session. A default that is free for
1101/// one audience and compounding for the other is not a neutral default.
1102///
1103/// Only the success lines go. A failure, a warning, a check that could not
1104/// run, a repaired file, and the blocked summary are printed under every
1105/// setting: quiet is about the uneventful path, and nothing else.
1106pub fn quiet(settings: &crate::config::Settings) -> bool {
1107    *settings.quiet.get_or_init(|| {
1108        decide(
1109            crate::config::enumerated_or(settings, "amont.quiet", QUIET_VALUES, "auto"),
1110            watching(),
1111        )
1112    })
1113}
1114
1115pub const QUIET_VALUES: &[&str] = &["never", "auto", "always"];
1116
1117/// Pure, so the three-way decision is testable without a terminal or a config.
1118fn decide(setting: &str, watching: bool) -> bool {
1119    match setting {
1120        "always" => true,
1121        "auto" => !watching,
1122        // `never`. A value `enumerated_or` rejected never reaches here — it
1123        // complains and hands back the default, which is now `auto`.
1124        _ => false,
1125    }
1126}
1127
1128/// Whether the capture mechanism is on at all. `amont.progress false` is the
1129/// escape hatch back to raw streaming — one knob, read once.
1130pub fn enabled(settings: &crate::config::Settings) -> bool {
1131    *settings
1132        .progress
1133        .get_or_init(|| crate::config::boolean_or(settings, "amont.progress", true))
1134}
1135
1136/// Is anyone watching? True only when stderr is a real terminal that speaks
1137/// VT: not piped, not redirected, not `TERM=dumb` — and on Windows only
1138/// with `TERM` actually set, because bare conhost may not interpret the
1139/// cursor codes the region depends on. This is the paint gate; capture
1140/// ([`enabled`]) does not consult it.
1141pub fn watching() -> bool {
1142    static WATCHING: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1143    *WATCHING.get_or_init(|| {
1144        if !std::io::stderr().is_terminal() {
1145            return false;
1146        }
1147        match std::env::var("TERM") {
1148            Ok(term) => term != "dumb",
1149            Err(_) => !cfg!(windows),
1150        }
1151    })
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156    use super::*;
1157
1158    fn test_settings() -> crate::config::Settings {
1159        crate::config::Settings::default()
1160    }
1161
1162    #[test]
1163    fn quiet_asks_who_is_reading() {
1164        assert!(!decide("never", true));
1165        assert!(!decide("never", false));
1166        assert!(always_and_auto_agree_at_a_terminal());
1167        assert!(decide("auto", false), "captured: nobody is watching");
1168        assert!(decide("always", true));
1169        assert!(decide("always", false));
1170        // An unreadable value has already been reported by `enumerated_or`,
1171        // which hands back the default; silence is never assumed.
1172        assert!(!decide("shhh", false));
1173    }
1174
1175    fn always_and_auto_agree_at_a_terminal() -> bool {
1176        !decide("auto", true) && decide("always", true)
1177    }
1178
1179    /// The atomicity contract at the unit level: two threads writing
1180    /// interleaved lines into their own slots come out as two contiguous
1181    /// buffers, whatever the scheduler did.
1182    #[test]
1183    fn slots_do_not_share_a_buffer() {
1184        let stage = Stage::begin(&test_settings(), &["a", "b"]);
1185        std::thread::scope(|scope| {
1186            for idx in 0..2 {
1187                let stage = Arc::clone(&stage);
1188                scope.spawn(move || {
1189                    let _guard = stage.enter(idx);
1190                    for i in 0..50 {
1191                        say(&format!("check-{idx} line-{i}"));
1192                        std::thread::yield_now();
1193                    }
1194                });
1195            }
1196        });
1197        let slots = stage.slots.lock().unwrap();
1198        for idx in 0..2 {
1199            let text = String::from_utf8(slots[idx].buf.clone()).unwrap();
1200            assert_eq!(text.lines().count(), 50);
1201            assert!(
1202                text.lines()
1203                    .all(|l| l.starts_with(&format!("check-{idx} "))),
1204                "a foreign line landed in slot {idx}"
1205            );
1206        }
1207    }
1208
1209    /// A thread with no sink prints; its lines never land in anyone's slot.
1210    #[test]
1211    fn no_sink_means_no_capture() {
1212        let stage = Stage::begin(&test_settings(), &["a"]);
1213        say("goes to stdout, not to a slot");
1214        let slots = stage.slots.lock().unwrap();
1215        assert!(slots[0].buf.is_empty());
1216    }
1217
1218    /// After finish, late writes are dropped rather than stranded — a child
1219    /// reader thread that outlives its check must not corrupt a later block.
1220    #[test]
1221    fn a_finished_slot_takes_no_more_writes() {
1222        let stage = Stage::begin(&test_settings(), &["a"]);
1223        stage.append_raw(0, b"before\n");
1224        stage.finish(&test_settings(), 0, None);
1225        stage.append_raw(0, b"after\n");
1226        let slots = stage.slots.lock().unwrap();
1227        assert!(slots[0].buf.is_empty(), "a write landed after finish");
1228    }
1229
1230    /// A repo-derived check name cannot smuggle control bytes onto a live
1231    /// terminal: sanitised at begin, once, for every later paint.
1232    #[test]
1233    fn a_slot_name_is_sanitised_at_begin() {
1234        let stage = Stage::begin(&test_settings(), &["evil\u{1b}[2Jname\rhere"]);
1235        let slots = stage.slots.lock().unwrap();
1236        assert!(!slots[0].name.contains('\u{1b}'), "{:?}", slots[0].name);
1237        assert!(!slots[0].name.contains('\r'), "{:?}", slots[0].name);
1238    }
1239
1240    /// Region names drop the stage's own prefix — it is the same twelve
1241    /// characters on every line.
1242    #[test]
1243    fn a_slot_name_drops_the_stage_prefix() {
1244        let stage = Stage::begin(
1245            &test_settings(),
1246            &["pre-commit-clippy", "pre-push-run-tests", "bare"],
1247        );
1248        let slots = stage.slots.lock().unwrap();
1249        assert_eq!(slots[0].name, "clippy");
1250        assert_eq!(slots[1].name, "run-tests");
1251        assert_eq!(slots[2].name, "bare");
1252    }
1253
1254    fn row(name: &str, elapsed: f64) -> Row {
1255        Row {
1256            name: name.into(),
1257            elapsed,
1258            quiet: 0.0,
1259            still: 0.0,
1260            cpu: RowCpu::None,
1261            wait: None,
1262            load: None,
1263            queued: None,
1264        }
1265    }
1266
1267    /// A check waiting for a host slot says so in the region, within 80
1268    /// columns, and in the heartbeat after its unchanged prefix.
1269    #[test]
1270    fn a_queued_check_says_it_waits_for_a_host_slot() {
1271        let mut r = row("pre-push-run-tests-js", 42.0);
1272        r.queued = Some((42.0, 2));
1273        let text = region(&[r.clone()], 80, B);
1274        assert!(text.contains("· queued 42s (slots 2/2)"), "{text:?}");
1275        assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1276        r.elapsed = 60.0;
1277        r.quiet = 60.0;
1278        r.queued = Some((60.0, 2));
1279        let beat = beat_line(&r, false, B, false);
1280        assert_eq!(
1281            beat,
1282            "  … pre-push-run-tests-js still running: 1m00s, last output 1m00s ago, \
1283             queued 1m00s for a host slot (amont.hostSlots 2)\n"
1284        );
1285    }
1286
1287    /// Under load the region counts toward the stretched budget and says
1288    /// so; the heartbeat names the budget, the load and the key; the first
1289    /// beat states the stretched figure.
1290    #[test]
1291    fn a_load_stretched_budget_is_shown_with_its_load() {
1292        let mut r = row("pre-push-run-tests-js", 240.0);
1293        r.quiet = 130.0;
1294        r.still = 130.0;
1295        r.cpu = RowCpu::Idle;
1296        r.load = Some((
1297            468,
1298            crate::load::Load {
1299                avg1_milli: 31_200,
1300                cores: 8,
1301            },
1302            3900,
1303        ));
1304        let text = region(&[r.clone()], 80, B);
1305        assert!(text.contains("· quiet 2m10s/7m48s (load ×3.9)"), "{text:?}");
1306        assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1307        let beat = beat_line(&r, false, B, false);
1308        assert!(
1309            beat.ends_with(
1310                ", CPU idle 2m10s, budget 7m48s (load avg 31.2 on 8 cores, ×3.9 — amont.idleLoadScale)\n"
1311            ),
1312            "{beat:?}"
1313        );
1314        let first = flat(&beat_line(&r, true, B, false));
1315        assert!(
1316            first.contains("killed after 7m48s with no output and under 0.1 core of CPU"),
1317            "{first:?}"
1318        );
1319    }
1320
1321    const B: Budgets = Budgets {
1322        idle: 120,
1323        ceiling: 3600,
1324        lock_wait: 600,
1325        extended: 480,
1326    };
1327
1328    /// While CPU is unmeasured the extended budget is the one that counts:
1329    /// the region counts toward it and says why, the heartbeat names it,
1330    /// and the first beat states that rule rather than the configured one.
1331    #[test]
1332    fn an_unmeasured_check_is_shown_against_the_extended_budget() {
1333        let mut r = row("pre-push-run-tests-js", 240.0);
1334        r.quiet = 130.0;
1335        r.still = 130.0;
1336        r.cpu = RowCpu::Unmeasured;
1337        let text = region(&[r.clone()], 80, B);
1338        assert!(
1339            text.contains("· quiet 2m10s/8m00s (CPU unmeasured)"),
1340            "{text:?}"
1341        );
1342        assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1343        let beat = beat_line(&r, false, B, false);
1344        assert!(
1345            beat.ends_with(", CPU unmeasured — extended budget 8m00s\n"),
1346            "{beat:?}"
1347        );
1348        let first = flat(&beat_line(&r, true, B, false));
1349        assert!(
1350            first.contains(
1351                "killed after 8m00s with no output while its CPU is unmeasured, or 1h00m in total"
1352            ),
1353            "{first:?}"
1354        );
1355        assert!(first.contains("amont.idleLoadScale"), "{first:?}");
1356    }
1357
1358    /// A declared wait replaces the silence countdown in the region and
1359    /// rides after the heartbeat's unchanged prefix, naming its budget; both
1360    /// fit 80 columns with the longest built-in name.
1361    #[test]
1362    fn a_declared_wait_is_shown_against_its_own_budget() {
1363        use crate::hooks::wait::{CargoLockWhat, WaitKind};
1364        let mut r = row("pre-push-run-tests-js", 240.0);
1365        r.quiet = 130.0;
1366        r.still = 0.0;
1367        r.cpu = RowCpu::Unmeasured;
1368        r.wait = Some((WaitKind::CargoLock(CargoLockWhat::BuildDirectory), 90.0));
1369        let text = region(&[r.clone()], 80, B);
1370        assert!(text.contains("· cargo lock 1m30s/10m00s"), "{text:?}");
1371        assert!(!text.contains("quiet"), "no silence countdown: {text:?}");
1372        assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1373        let until = Budgets { lock_wait: 0, ..B };
1374        let text = region(&[r.clone()], 80, until);
1375        assert!(
1376            text.contains("· cargo lock 1m30s") && !text.contains("1m30s/"),
1377            "{text:?}"
1378        );
1379
1380        let beat = beat_line(&r, false, B, false);
1381        assert_eq!(
1382            beat,
1383            "  … pre-push-run-tests-js still running: 4m00s, last output 2m10s ago, \
1384             waiting for the cargo lock on the build directory 1m30s (amont.lockWait 10m00s)\n"
1385        );
1386        let beat = beat_line(&r, false, until, false);
1387        assert!(
1388            beat.contains("(amont.lockWait 0: until the ceiling)"),
1389            "{beat:?}"
1390        );
1391        // The first beat states the wait's rule, not the CPU one: the
1392        // silence clock is not running.
1393        let first = flat(&beat_line(&r, true, B, false));
1394        assert!(
1395            first.contains("(killed after 10m00s in the wait, or 1h00m in total — amont.lockWait"),
1396            "{first:?}"
1397        );
1398        assert!(!first.contains("unmeasured"), "{first:?}");
1399    }
1400
1401    /// The spinner frame comes from the clock: different elapsed, different
1402    /// frame; same elapsed, same frame.
1403    #[test]
1404    fn frames_advance_with_time() {
1405        let a = region(&[row("clippy", 0.0)], 80, B);
1406        let b = region(&[row("clippy", 0.1)], 80, B);
1407        let c = region(&[row("clippy", 1.0)], 80, B);
1408        assert_ne!(a.chars().next(), b.chars().next());
1409        assert_eq!(a.chars().next(), c.chars().next(), "10 frames per second");
1410    }
1411
1412    /// Names pad to a column so the elapsed figures align — across the
1413    /// minute mark too, where the figure changes shape.
1414    #[test]
1415    fn region_lines_align() {
1416        let text = region(&[row("a", 0.0), row("longer-name", 0.0)], 80, B);
1417        let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
1418        assert_eq!(widths[0], widths[1], "{text:?}");
1419        let text = region(&[row("a", 3.2), row("b", 492.0)], 80, B);
1420        let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
1421        assert_eq!(widths[0], widths[1], "{text:?}");
1422        assert!(text.contains("8m12s"), "{text:?}");
1423    }
1424
1425    /// Thirteen running checks paint as twelve lines and one overflow.
1426    #[test]
1427    fn region_caps_and_counts_the_rest() {
1428        let entries: Vec<Row> = (0..13).map(|i| row(&format!("check-{i}"), 0.0)).collect();
1429        let text = region(&entries, 80, B);
1430        assert_eq!(text.lines().count(), MAX_LINES + 1);
1431        assert!(text.ends_with("… and 1 more\n"), "{text:?}");
1432    }
1433
1434    /// A narrow terminal truncates rather than wraps — a wrapped region
1435    /// line would break the erase arithmetic.
1436    #[test]
1437    fn region_respects_width() {
1438        let text = region(&[row("a-name-much-longer-than-the-terminal", 0.0)], 20, B);
1439        assert!(text.lines().all(|l| l.chars().count() <= 20), "{text:?}");
1440    }
1441
1442    /// No running checks, no region — not even a blank line.
1443    #[test]
1444    fn an_empty_region_is_empty() {
1445        assert_eq!(region(&[], 80, B), "");
1446    }
1447
1448    /// Silence is annotated only once it is news, and names the budget it
1449    /// counts toward — a check that just paused between crates says
1450    /// nothing extra.
1451    #[test]
1452    fn a_quiet_check_shows_its_silence_against_the_budget() {
1453        let mut r = row("cargo-test", 300.0);
1454        r.quiet = 5.0;
1455        assert!(!region(&[r.clone()], 80, B).contains("quiet"));
1456        r.quiet = 45.0;
1457        let text = region(&[r.clone()], 80, B);
1458        assert!(text.contains("quiet 45s/2m00s"), "{text:?}");
1459        let off = Budgets { idle: 0, ..B };
1460        let text = region(&[r], 80, off);
1461        assert!(
1462            text.contains("quiet 45s") && !text.contains('/'),
1463            "{text:?}"
1464        );
1465    }
1466
1467    /// A silent check that is working shows how hard, with no countdown —
1468    /// no kill is coming; one whose CPU work pushed the still-time back
1469    /// counts down the still-time, which is what the kill decision uses.
1470    /// Both fit an 80-column terminal with a longish name.
1471    #[test]
1472    fn a_quiet_busy_check_shows_cores_and_an_idle_one_counts_down_the_still_time() {
1473        let mut r = row("vitest-workspace", 240.0);
1474        r.quiet = 130.0;
1475        r.still = 130.0;
1476        r.cpu = RowCpu::Busy(3900);
1477        let busy = region(&[r.clone()], 80, B);
1478        assert!(busy.contains("· quiet 2m10s · ~3.9 cores"), "{busy:?}");
1479        assert!(
1480            !busy.contains("/2m00s"),
1481            "no countdown while busy: {busy:?}"
1482        );
1483
1484        r.cpu = RowCpu::Idle;
1485        r.still = 40.0;
1486        let idle = region(&[r.clone()], 80, B);
1487        assert!(idle.contains("· quiet 2m10s · idle 40s/2m00s"), "{idle:?}");
1488
1489        r.cpu = RowCpu::Unmeasured;
1490        r.still = 130.0;
1491        let plain = region(&[r], 80, B);
1492        assert!(
1493            plain.contains("· quiet 2m10s/8m00s (CPU unmeasured)"),
1494            "{plain:?}"
1495        );
1496
1497        for text in [busy, idle, plain] {
1498            assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1499        }
1500    }
1501
1502    /// The heartbeat's prefix is unchanged — log readers grep it — and the
1503    /// CPU state rides after it.
1504    #[test]
1505    fn a_heartbeat_appends_the_cpu_state_after_an_unchanged_prefix() {
1506        let mut r = row("vitest", 240.0);
1507        r.quiet = 130.0;
1508        r.still = 40.0;
1509        let prefix = "  … vitest still running: 4m00s, last output 2m10s ago";
1510        for (cpu, suffix) in [
1511            (RowCpu::Busy(3900), ", busy ~3.9 cores\n"),
1512            (RowCpu::Idle, ", CPU idle 40s\n"),
1513            (
1514                RowCpu::Unmeasured,
1515                ", CPU unmeasured — extended budget 8m00s\n",
1516            ),
1517            (RowCpu::None, "\n"),
1518            (RowCpu::NotSampled, "\n"),
1519        ] {
1520            r.cpu = cpu;
1521            assert_eq!(beat_line(&r, false, B, false), format!("{prefix}{suffix}"));
1522        }
1523    }
1524
1525    /// The first beat states the rule that actually applies to this check.
1526    #[test]
1527    fn the_first_beat_states_the_rule_in_force() {
1528        let mut r = row("vitest", 60.0);
1529        r.cpu = RowCpu::Idle;
1530        let sampled = beat_line(&r, true, B, false);
1531        assert!(
1532            flat(&sampled).contains(
1533                "killed after 2m00s with no output and under 0.1 core of CPU, or 1h00m in total"
1534            ),
1535            "{sampled:?}"
1536        );
1537        r.cpu = RowCpu::NotSampled;
1538        let not = beat_line(&r, true, B, false);
1539        assert!(
1540            not.contains("2m00s of silence or 1h00m in total"),
1541            "{not:?}"
1542        );
1543        assert!(
1544            not.contains("CPU not sampled here, silence alone counts"),
1545            "{not:?}"
1546        );
1547    }
1548
1549    /// The ceiling appears once a check is 80% of the way to it — the cliff,
1550    /// shown before the fall — and never for a disabled ceiling.
1551    #[test]
1552    fn the_ceiling_shows_only_when_it_is_near() {
1553        assert!(!region(&[row("cargo-test", 1000.0)], 80, B).contains("/1h00m"));
1554        let text = region(&[row("cargo-test", 3000.0)], 80, B);
1555        assert!(text.contains("50m00s/1h00m"), "{text:?}");
1556        let off = Budgets { ceiling: 0, ..B };
1557        assert!(!region(&[row("cargo-test", 3000.0)], 80, off).contains("/"));
1558    }
1559
1560    /// The heartbeat says how long, how quiet, and — the first time — the
1561    /// budgets, so a reader at the far end of a pipe can tell "wait" from
1562    /// "kill it" without the docs.
1563    #[test]
1564    fn a_heartbeat_names_the_budgets_once() {
1565        let mut r = row("cargo-test", 60.0);
1566        r.quiet = 2.0;
1567        let first = beat_line(&r, true, B, false);
1568        assert!(
1569            first.contains("cargo-test still running: 1m00s"),
1570            "{first:?}"
1571        );
1572        assert!(first.contains("last output 2s ago"), "{first:?}");
1573        assert!(
1574            first.contains("2m00s of silence or 1h00m in total"),
1575            "{first:?}"
1576        );
1577        assert!(first.contains("amont.idleTimeout"), "{first:?}");
1578        let later = beat_line(&r, false, B, false);
1579        assert!(!later.contains("amont.idleTimeout"), "{later:?}");
1580        let off = beat_line(
1581            &r,
1582            true,
1583            Budgets {
1584                idle: 0,
1585                ceiling: 0,
1586                lock_wait: 0,
1587                extended: 0,
1588            },
1589            false,
1590        );
1591        assert!(off.contains("off of silence or off in total"), "{off:?}");
1592    }
1593
1594    /// The message, with newlines and indentation flattened.
1595    ///
1596    /// The note is wrapped for a terminal, so a literal substring can fall
1597    /// across a line break — asserting on `"may close it first"` failed for
1598    /// no better reason than that `may` ended a line. These tests are about
1599    /// what the message SAYS; re-wrapping it should not break them.
1600    fn flat(line: &str) -> String {
1601        line.split_whitespace().collect::<Vec<_>>().join(" ")
1602    }
1603
1604    /// A long PUSH gate is told what it is sitting on; a commit gate is not.
1605    ///
1606    /// The three negatives matter as much as the positive. Said on every
1607    /// beat it would be nagging; said at commit time it would be false —
1608    /// there is no connection open — and a future refactor that wires
1609    /// `on_push` to a constant would show up here and nowhere else.
1610    #[test]
1611    fn a_long_push_gate_is_told_what_it_is_sitting_on() {
1612        let r = row("cargo-test", 60.0);
1613
1614        let pushing = flat(&beat_line(&r, true, B, true));
1615        assert!(
1616            pushing.contains("A remote may close it first"),
1617            "{pushing:?}"
1618        );
1619        assert!(pushing.contains("Connection reset by peer"), "{pushing:?}");
1620        assert!(
1621            pushing.contains("Moving a gate entry earlier"),
1622            "{pushing:?}"
1623        );
1624
1625        // Once, not every minute.
1626        let later = flat(&beat_line(&r, false, B, true));
1627        assert!(!later.contains("close it first"), "{later:?}");
1628
1629        // Never at commit time: nothing is waiting on a socket there.
1630        let committing = flat(&beat_line(&r, true, B, false));
1631        assert!(!committing.contains("close it first"), "{committing:?}");
1632    }
1633
1634    /// The advice that does NOT appear, and must not come back.
1635    ///
1636    /// `ServerAliveInterval 60` is the obvious suggestion and it is wrong:
1637    /// it was already in force on the machine where this failure was
1638    /// diagnosed, and the remote reset the connection regardless. Telling
1639    /// every amont user to set it would be confident, actionable and
1640    /// useless. This test exists so that a future reader who has the same
1641    /// obvious idea meets an argument instead of a blank.
1642    #[test]
1643    fn the_push_note_does_not_recommend_ssh_keepalive() {
1644        let r = row("cargo-test", 60.0);
1645        let pushing = flat(&beat_line(&r, true, B, true));
1646        assert!(
1647            !pushing.contains("ServerAlive"),
1648            "keepalive was already on when this failed; recommending it \
1649             would be useless advice: {pushing:?}"
1650        );
1651        assert!(
1652            pushing.contains("ssh keepalive does not prevent this"),
1653            "say so, rather than leaving the reader to try it: {pushing:?}"
1654        );
1655    }
1656}