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    // The active-run entry carries the check's result, not the tool's: a
672    // failure with a runner exit of 0 is still a failure in relais's pane.
673    let exit = match (end.outcome, failed) {
674        (None, _) => None,
675        (Some(_), true) => Some(end.runner_exit.filter(|c| *c != 0).unwrap_or(1)),
676        (Some(_), false) => Some(0),
677    };
678    end.capture.end_entry(exit, stored.path());
679    let passed = end.outcome == Some(crate::check::Outcome::Passed);
680    if end.capture.seen == 0 {
681        // No tool output: a check that runs no tool (ban-terms, secrets), or
682        // one the push stage skipped before it ran. Exactly what it printed
683        // before quiet captured anything — under quiet, nothing on a pass —
684        // and no empty log left behind. A tool that ran and printed nothing
685        // on a failure says so.
686        if let Some(p) = stored.path() {
687            let _ = std::fs::remove_file(p);
688        }
689        let mut head = end.said;
690        if failed && end.runner_exit.is_some() {
691            head.extend_from_slice(b"(no output)\n");
692            if let Some(code) = end.runner_exit.filter(|c| *c != 0) {
693                head.extend_from_slice(
694                    format!("{} failed, runner exit {code}\n", end.name).as_bytes(),
695                );
696            }
697        }
698        return QuietOut::plain(head);
699    }
700    if passed && !end.ok_line {
701        // In a stage: silent, like every passing check under quiet; the log
702        // stays on disk for whoever wants it.
703        return QuietOut::plain(end.said);
704    }
705    let path = stored.path().map(|p| p.display().to_string());
706    if passed {
707        let mut out = format!("ok {} {:.1}s", end.name, end.secs);
708        if let (crate::capture::Stored::Saved(_), Some(p)) = (&stored, &path) {
709            out.push_str(&format!(" log={p}"));
710        }
711        out.push('\n');
712        if let Some(note) = stored.note() {
713            out.push_str(&note);
714            out.push('\n');
715        }
716        return QuietOut::plain(out.into_bytes());
717    }
718    let excerpt = end.capture.acc.finish(path.as_deref().unwrap_or("-"));
719    let mut tail = String::new();
720    if failed {
721        if let Some(code) = end.runner_exit.filter(|c| *c != 0) {
722            tail.push_str(&format!("{} failed, runner exit {code}\n", end.name));
723        }
724    }
725    if let Some(p) = &path {
726        tail.push_str(&format!("full log: {p}\n"));
727    }
728    if let Some(note) = stored.note() {
729        tail.push_str(&note);
730        tail.push('\n');
731    }
732    // Under CI a failure prints its whole log, copied from the file when the
733    // block is written: the file goes away with the runner. The excerpt is
734    // what prints if the copy cannot be made.
735    match (&stored, end.ci && failed) {
736        (crate::capture::Stored::Saved(p), true) => QuietOut {
737            head: end.said,
738            stream: Some((p.clone(), excerpt)),
739            tail: tail.into_bytes(),
740        },
741        _ => {
742            let mut head = end.said;
743            head.extend_from_slice(excerpt.as_bytes());
744            QuietOut {
745                head,
746                stream: None,
747                tail: tail.into_bytes(),
748            }
749        }
750    }
751}
752
753/// A finished slot's block: `head`, then a log file copied out whole (with
754/// the excerpt to print if the copy fails), then `tail`.
755struct QuietOut {
756    head: Vec<u8>,
757    stream: Option<(std::path::PathBuf, String)>,
758    tail: Vec<u8>,
759}
760
761impl QuietOut {
762    fn plain(head: Vec<u8>) -> QuietOut {
763        QuietOut {
764            head,
765            stream: None,
766            tail: Vec::new(),
767        }
768    }
769
770    fn is_empty(&self) -> bool {
771        self.head.is_empty() && self.stream.is_none() && self.tail.is_empty()
772    }
773
774    fn write(self, to: &mut impl Write) {
775        let _ = to.write_all(&self.head);
776        if let Some((path, excerpt)) = self.stream {
777            let copied = std::fs::File::open(&path).and_then(|mut f| std::io::copy(&mut f, to));
778            if let Err(e) = copied {
779                let _ = to.write_all(excerpt.as_bytes());
780                let _ = writeln!(to, "log not read: {e}");
781            }
782        }
783        let _ = to.write_all(&self.tail);
784    }
785}
786
787/// Whether an outcome is a failure for the quiet block: the failure line
788/// and, under CI, the whole log. A check whose thread died (`None`) has not
789/// passed. Every variant named, so a new one is a compile error here.
790fn is_failure(outcome: Option<crate::check::Outcome>) -> bool {
791    use crate::check::Outcome;
792    match outcome {
793        None => true,
794        Some(Outcome::Failed) => true,
795        Some(
796            Outcome::Passed
797            | Outcome::Warned
798            | Outcome::Fixed
799            | Outcome::Unavailable
800            | Outcome::Inert,
801        ) => false,
802    }
803}
804
805fn heartbeat(settings: crate::config::Settings, weak: Weak<Stage>) {
806    loop {
807        std::thread::sleep(std::time::Duration::from_secs(1));
808        let Some(stage) = weak.upgrade() else { return };
809        if stage.stop.load(Ordering::Relaxed) {
810            return;
811        }
812        let mut announce: Vec<String> = Vec::new();
813        let due: Vec<(Row, bool)> = {
814            let mut slots = stage.slots.lock().unwrap_or_else(|p| p.into_inner());
815            let now = Instant::now();
816            let mut due = Vec::new();
817            for s in slots.iter_mut().filter(|s| s.running && !s.done) {
818                let elapsed = now.duration_since(s.started).as_secs();
819                // Only a log the tool has written to: a check that prints
820                // nothing keeps no log, and the line would point at nothing.
821                if !s.announced && elapsed >= ANNOUNCE_SECS {
822                    if let Some(c) = s.capture.as_ref().filter(|c| c.writing() && c.seen > 0) {
823                        s.announced = true;
824                        announce.push(format!("running {}, log {}\n", s.name, c.tmp.display()));
825                    }
826                }
827                if elapsed >= s.next_beat {
828                    let first = s.next_beat == HEARTBEAT_SECS;
829                    s.next_beat += HEARTBEAT_SECS;
830                    due.push((row_of(s, now, elapsed as f64), first));
831                }
832            }
833            due
834        };
835        if due.is_empty() && announce.is_empty() {
836            continue;
837        }
838        let text: String = announce.concat()
839            + &due
840                .iter()
841                .map(|(row, first)| beat_line(row, *first, budgets(&settings), stage.on_push))
842                .collect::<String>();
843        let _guard = stage.out.lock().unwrap_or_else(|p| p.into_inner());
844        let mut err = std::io::stderr().lock();
845        let _ = err.write_all(text.as_bytes());
846        let _ = err.flush();
847    }
848}
849
850/// One heartbeat line. Pure, for the tests.
851///
852/// On the FIRST beat of a PUSH gate it also names something no other part of
853/// the system is placed to explain. `git push` opens its connection to the
854/// remote, reads the remote refs — which is where the `pre-push` hook's own
855/// stdin comes from — and only then calls the hook. The connection is
856/// therefore already open and goes idle for exactly as long as the gate
857/// runs, and a remote may close it before the gate finishes. git then
858/// reports `Connection reset by peer`, which reads as a network fault and
859/// says nothing about the seven minutes that caused it.
860///
861/// The note does NOT recommend ssh keepalive, and that omission is
862/// deliberate: `ServerAliveInterval 60` was already in force on the machine
863/// where this was diagnosed, and GitHub reset the connection anyway.
864/// Whatever the remote is measuring, it is not packets. Recommending it
865/// would be a confident instruction to change a setting that is probably
866/// already on and cannot help, so the note says so and points at the thing
867/// that does work.
868///
869/// Only on a first beat, so it is said once; only on a push, so a commit
870/// gate never hears it. A first beat is a check that has already run a full
871/// minute, which is the population at risk — no threshold to invent.
872fn beat_line(row: &Row, first: bool, budgets: Budgets, on_push: bool) -> String {
873    use crate::hooks::common::human_secs;
874    // The prefix is byte-for-byte what it always was: log readers grep it.
875    // What CPU sampling adds goes after it.
876    let mut line = format!(
877        "  … {} still running: {}, last output {} ago",
878        row.name,
879        human_secs(row.elapsed as u64),
880        human_secs(row.quiet as u64)
881    );
882    if let Some((secs, n)) = row.queued {
883        line.push_str(&format!(
884            ", queued {} for a host slot (amont.hostSlots {n})",
885            human_secs(secs as u64)
886        ));
887    } else if let Some((kind, waited)) = row.wait {
888        line.push_str(&format!(
889            ", waiting for {} {}",
890            kind.describe(),
891            human_secs(waited as u64)
892        ));
893        match budgets.lock_wait {
894            0 => line.push_str(" (amont.lockWait 0: until the ceiling)"),
895            s => line.push_str(&format!(" (amont.lockWait {})", human_secs(s))),
896        }
897    } else {
898        match row.cpu {
899            RowCpu::Busy(m) => line.push_str(&format!(", busy {}", crate::hooks::common::cores(m))),
900            RowCpu::Idle => line.push_str(&format!(", CPU idle {}", human_secs(row.still as u64))),
901            RowCpu::Unmeasured if budgets.idle > 0 => line.push_str(&format!(
902                ", CPU unmeasured — extended budget {}",
903                human_secs(budgets.extended.max(budgets.idle))
904            )),
905            RowCpu::Unmeasured => line.push_str(", CPU unmeasured"),
906            RowCpu::None | RowCpu::NotSampled => {}
907        }
908        if let (Some((applied, load, factor)), false) = (row.load, row.cpu == RowCpu::Unmeasured) {
909            line.push_str(&format!(
910                ", budget {} (load avg {} on {} cores, {} — amont.idleLoadScale)",
911                human_secs(applied),
912                load.avg1_text(),
913                load.cores,
914                crate::load::factor_text(factor)
915            ));
916        }
917    }
918    if first {
919        let idle = match row.applied_idle(budgets) {
920            0 => "off".to_string(),
921            s => human_secs(s),
922        };
923        let ceiling = match budgets.ceiling {
924            0 => "off".to_string(),
925            s => human_secs(s),
926        };
927        match row.cpu {
928            // In a declared wait the silence clock is not running: the rule
929            // in force is the wait's own budget.
930            _ if row.wait.is_some() => line.push_str(&format!(
931                " (killed after {} in the wait, or {ceiling} in total — amont.lockWait / \
932                 amont.timeout)",
933                match budgets.lock_wait {
934                    0 => ceiling.clone(),
935                    s => human_secs(s),
936                }
937            )),
938            // The budget that applies to THIS check, not the configured
939            // one: while CPU is unmeasured that is the extended budget.
940            RowCpu::Unmeasured if budgets.idle > 0 => line.push_str(&format!(
941                " (killed after {} with no output while its CPU is unmeasured, or {ceiling} \
942                 in total — amont.idleTimeout × amont.idleLoadScale / amont.timeout)",
943                human_secs(budgets.extended.max(budgets.idle))
944            )),
945            RowCpu::Busy(_) | RowCpu::Idle if budgets.idle > 0 => line.push_str(&format!(
946                " (killed after {idle} with no output and under 0.1 core of CPU, or \
947                 {ceiling} in total — amont.idleTimeout / amont.timeout)"
948            )),
949            _ => line.push_str(&format!(
950                " (killed after {idle} of silence or {ceiling} in total — amont.idleTimeout / amont.timeout)"
951            )),
952        }
953        if row.cpu == RowCpu::NotSampled && budgets.idle > 0 {
954            line.push_str("; CPU not sampled here, silence alone counts");
955        }
956        if on_push {
957            // `concat!`, not a `\`-continued literal: a continuation keeps
958            // the next line's indentation, which turns the message into runs
959            // of spaces. Each line is its own literal and the newlines are
960            // written down, so what is here is what a reader sees.
961            line.push_str(concat!(
962                "\n    git opened its connection to the remote before calling this",
963                "\n    gate, and it stays idle until the gate finishes. A remote may",
964                "\n    close it first — GitHub does — and the push then fails with",
965                "\n    \"Connection reset by peer\", naming the network rather than the",
966                "\n    wait. ssh keepalive does not prevent this.",
967                "\n    Declaring this check at pre-commit moves it off the push path —",
968                "\n    see \"Moving a gate entry earlier\" in the docs.",
969            ));
970        }
971    }
972    line.push('\n');
973    line
974}
975
976/// `$COLUMNS` when it is exported and sane, else a conservative 80 — the
977/// region's lines are short and an ioctl is not worth its portability. 80,
978/// not wider: shells rarely export `COLUMNS`, and a region line longer than
979/// the real terminal wraps, which breaks the erase arithmetic.
980///
981/// `pub` is now wider than it needs to be — the out-of-crate caller that
982/// justified it, `amont-agent`, is its own project and carries its own copy.
983/// Left public rather than narrowed in the same change that removed it.
984pub fn term_width() -> usize {
985    std::env::var("COLUMNS")
986        .ok()
987        .and_then(|c| c.parse::<usize>().ok())
988        .filter(|w| *w >= 20)
989        .unwrap_or(80)
990}
991
992/// Emits slot `idx`'s block when dropped — however the check's closure
993/// exits, a panic included: the partial output of a check that died still
994/// reaches the reader, above the dead-check verdict the runner fills in.
995pub struct FinishOnDrop<'a> {
996    stage: &'a Stage,
997    idx: usize,
998    /// The check's outcome once it has one; `None` when its thread died.
999    outcome: std::cell::Cell<Option<crate::check::Outcome>>,
1000    /// Carried, because `Drop` takes no arguments and the finish paint
1001    /// needs the budgets. Same lifetime as the stage it belongs to.
1002    settings: &'a crate::config::Settings,
1003}
1004
1005impl<'a> FinishOnDrop<'a> {
1006    pub fn new(
1007        settings: &'a crate::config::Settings,
1008        stage: &'a Stage,
1009        idx: usize,
1010    ) -> FinishOnDrop<'a> {
1011        FinishOnDrop {
1012            stage,
1013            idx,
1014            outcome: std::cell::Cell::new(None),
1015            settings,
1016        }
1017    }
1018
1019    /// Record what the check returned, for the quiet block.
1020    pub fn outcome(&self, outcome: crate::check::Outcome) {
1021        self.outcome.set(Some(outcome));
1022    }
1023}
1024
1025impl Drop for FinishOnDrop<'_> {
1026    fn drop(&mut self) {
1027        self.stage
1028            .finish(self.settings, self.idx, self.outcome.get());
1029    }
1030}
1031
1032/// Uninstalls the thread's sink on drop, whatever path the check took out.
1033pub struct SinkGuard;
1034
1035impl Drop for SinkGuard {
1036    fn drop(&mut self) {
1037        SINK.with(|s| *s.borrow_mut() = None);
1038    }
1039}
1040
1041/// Detaches a command from its slot's displays when dropped. See
1042/// [`Stage::attach`].
1043pub struct AttachGuard {
1044    stage: Arc<Stage>,
1045    idx: usize,
1046}
1047
1048impl Drop for AttachGuard {
1049    fn drop(&mut self) {
1050        let mut slots = self.stage.slots.lock().unwrap_or_else(|p| p.into_inner());
1051        if let Some(slot) = slots.get_mut(self.idx) {
1052            slot.activity = None;
1053        }
1054    }
1055}
1056
1057/// The sink installed on THIS thread, if any — how a child-capture helper on
1058/// the check's own thread learns where the reader threads should append.
1059pub fn current_sink() -> Option<(Arc<Stage>, usize)> {
1060    SINK.with(|s| s.borrow().clone())
1061}
1062
1063/// One line of check output, wherever it should go.
1064///
1065/// THE funnel: `common::ok/fail/warn` call this, so a check's helper prints
1066/// land in its slot during a stage and on stdout everywhere else. `line` is
1067/// taken without a trailing newline, exactly like `println!`.
1068pub fn say(line: &str) {
1069    let routed = SINK.with(|s| {
1070        s.borrow().as_ref().map(|(stage, idx)| {
1071            stage.append_line(*idx, line);
1072        })
1073    });
1074    if routed.is_none() {
1075        println!("{line}");
1076    }
1077}
1078
1079/// `println!`, stage-aware: formats and routes through [`say`]. What every
1080/// direct print inside a CHECK BODY becomes — a line printed raw from a
1081/// check thread bypasses the slot and interleaves, which is the bug this
1082/// module exists to close.
1083#[macro_export]
1084macro_rules! say {
1085    ($($arg:tt)*) => {
1086        $crate::live::say(&format!($($arg)*))
1087    };
1088}
1089
1090/// Should a check's SUCCESS line be swallowed?
1091///
1092/// A hook that passes says one line per check, and on a clean run that is the
1093/// entire output: fourteen lines to say nothing happened. At a terminal those
1094/// lines are the reassurance that the gate ran. Captured — an agent's tool
1095/// result, a CI log — they are re-read on every later turn of the session and
1096/// say no more the tenth time than the first.
1097///
1098/// So the setting names WHO is reading, not how loud to be:
1099///
1100/// - `auto` (default) — quiet when nobody is watching, verbose at a terminal.
1101/// - `never` — every check says it passed, whoever is reading.
1102/// - `always` — quiet everywhere.
1103///
1104/// `auto` is the default because the reader it costs nothing is the one at a
1105/// terminal: `watching()` is true there, so a person sees exactly what they
1106/// saw before. The reader it saves is the one who cannot skim — a captured
1107/// log, an agent's tool result — and that reader was paying for fourteen
1108/// lines of nothing on every turn of a session. A default that is free for
1109/// one audience and compounding for the other is not a neutral default.
1110///
1111/// Only the success lines go. A failure, a warning, a check that could not
1112/// run, a repaired file, and the blocked summary are printed under every
1113/// setting: quiet is about the uneventful path, and nothing else.
1114pub fn quiet(settings: &crate::config::Settings) -> bool {
1115    *settings.quiet.get_or_init(|| {
1116        decide(
1117            crate::config::enumerated_or(settings, "amont.quiet", QUIET_VALUES, "auto"),
1118            watching(),
1119        )
1120    })
1121}
1122
1123pub const QUIET_VALUES: &[&str] = &["never", "auto", "always"];
1124
1125/// Pure, so the three-way decision is testable without a terminal or a config.
1126fn decide(setting: &str, watching: bool) -> bool {
1127    match setting {
1128        "always" => true,
1129        "auto" => !watching,
1130        // `never`. A value `enumerated_or` rejected never reaches here — it
1131        // complains and hands back the default, which is now `auto`.
1132        _ => false,
1133    }
1134}
1135
1136/// Whether the capture mechanism is on at all. `amont.progress false` is the
1137/// escape hatch back to raw streaming — one knob, read once.
1138pub fn enabled(settings: &crate::config::Settings) -> bool {
1139    *settings
1140        .progress
1141        .get_or_init(|| crate::config::boolean_or(settings, "amont.progress", true))
1142}
1143
1144/// Is anyone watching? True only when stderr is a real terminal that speaks
1145/// VT: not piped, not redirected, not `TERM=dumb` — and on Windows only
1146/// with `TERM` actually set, because bare conhost may not interpret the
1147/// cursor codes the region depends on. This is the paint gate; capture
1148/// ([`enabled`]) does not consult it.
1149pub fn watching() -> bool {
1150    static WATCHING: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1151    *WATCHING.get_or_init(|| {
1152        if !std::io::stderr().is_terminal() {
1153            return false;
1154        }
1155        match std::env::var("TERM") {
1156            Ok(term) => term != "dumb",
1157            Err(_) => !cfg!(windows),
1158        }
1159    })
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164    use super::*;
1165
1166    fn test_settings() -> crate::config::Settings {
1167        crate::config::Settings::default()
1168    }
1169
1170    #[test]
1171    fn quiet_asks_who_is_reading() {
1172        assert!(!decide("never", true));
1173        assert!(!decide("never", false));
1174        assert!(always_and_auto_agree_at_a_terminal());
1175        assert!(decide("auto", false), "captured: nobody is watching");
1176        assert!(decide("always", true));
1177        assert!(decide("always", false));
1178        // An unreadable value has already been reported by `enumerated_or`,
1179        // which hands back the default; silence is never assumed.
1180        assert!(!decide("shhh", false));
1181    }
1182
1183    fn always_and_auto_agree_at_a_terminal() -> bool {
1184        !decide("auto", true) && decide("always", true)
1185    }
1186
1187    /// The atomicity contract at the unit level: two threads writing
1188    /// interleaved lines into their own slots come out as two contiguous
1189    /// buffers, whatever the scheduler did.
1190    #[test]
1191    fn slots_do_not_share_a_buffer() {
1192        let stage = Stage::begin(&test_settings(), &["a", "b"]);
1193        std::thread::scope(|scope| {
1194            for idx in 0..2 {
1195                let stage = Arc::clone(&stage);
1196                scope.spawn(move || {
1197                    let _guard = stage.enter(idx);
1198                    for i in 0..50 {
1199                        say(&format!("check-{idx} line-{i}"));
1200                        std::thread::yield_now();
1201                    }
1202                });
1203            }
1204        });
1205        let slots = stage.slots.lock().unwrap();
1206        for idx in 0..2 {
1207            let text = String::from_utf8(slots[idx].buf.clone()).unwrap();
1208            assert_eq!(text.lines().count(), 50);
1209            assert!(
1210                text.lines()
1211                    .all(|l| l.starts_with(&format!("check-{idx} "))),
1212                "a foreign line landed in slot {idx}"
1213            );
1214        }
1215    }
1216
1217    /// A thread with no sink prints; its lines never land in anyone's slot.
1218    #[test]
1219    fn no_sink_means_no_capture() {
1220        let stage = Stage::begin(&test_settings(), &["a"]);
1221        say("goes to stdout, not to a slot");
1222        let slots = stage.slots.lock().unwrap();
1223        assert!(slots[0].buf.is_empty());
1224    }
1225
1226    /// After finish, late writes are dropped rather than stranded — a child
1227    /// reader thread that outlives its check must not corrupt a later block.
1228    #[test]
1229    fn a_finished_slot_takes_no_more_writes() {
1230        let stage = Stage::begin(&test_settings(), &["a"]);
1231        stage.append_raw(0, b"before\n");
1232        stage.finish(&test_settings(), 0, None);
1233        stage.append_raw(0, b"after\n");
1234        let slots = stage.slots.lock().unwrap();
1235        assert!(slots[0].buf.is_empty(), "a write landed after finish");
1236    }
1237
1238    /// A repo-derived check name cannot smuggle control bytes onto a live
1239    /// terminal: sanitised at begin, once, for every later paint.
1240    #[test]
1241    fn a_slot_name_is_sanitised_at_begin() {
1242        let stage = Stage::begin(&test_settings(), &["evil\u{1b}[2Jname\rhere"]);
1243        let slots = stage.slots.lock().unwrap();
1244        assert!(!slots[0].name.contains('\u{1b}'), "{:?}", slots[0].name);
1245        assert!(!slots[0].name.contains('\r'), "{:?}", slots[0].name);
1246    }
1247
1248    /// Region names drop the stage's own prefix — it is the same twelve
1249    /// characters on every line.
1250    #[test]
1251    fn a_slot_name_drops_the_stage_prefix() {
1252        let stage = Stage::begin(
1253            &test_settings(),
1254            &["pre-commit-clippy", "pre-push-run-tests", "bare"],
1255        );
1256        let slots = stage.slots.lock().unwrap();
1257        assert_eq!(slots[0].name, "clippy");
1258        assert_eq!(slots[1].name, "run-tests");
1259        assert_eq!(slots[2].name, "bare");
1260    }
1261
1262    fn row(name: &str, elapsed: f64) -> Row {
1263        Row {
1264            name: name.into(),
1265            elapsed,
1266            quiet: 0.0,
1267            still: 0.0,
1268            cpu: RowCpu::None,
1269            wait: None,
1270            load: None,
1271            queued: None,
1272        }
1273    }
1274
1275    /// A check waiting for a host slot says so in the region, within 80
1276    /// columns, and in the heartbeat after its unchanged prefix.
1277    #[test]
1278    fn a_queued_check_says_it_waits_for_a_host_slot() {
1279        let mut r = row("pre-push-run-tests-js", 42.0);
1280        r.queued = Some((42.0, 2));
1281        let text = region(&[r.clone()], 80, B);
1282        assert!(text.contains("· queued 42s (slots 2/2)"), "{text:?}");
1283        assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1284        r.elapsed = 60.0;
1285        r.quiet = 60.0;
1286        r.queued = Some((60.0, 2));
1287        let beat = beat_line(&r, false, B, false);
1288        assert_eq!(
1289            beat,
1290            "  … pre-push-run-tests-js still running: 1m00s, last output 1m00s ago, \
1291             queued 1m00s for a host slot (amont.hostSlots 2)\n"
1292        );
1293    }
1294
1295    /// Under load the region counts toward the stretched budget and says
1296    /// so; the heartbeat names the budget, the load and the key; the first
1297    /// beat states the stretched figure.
1298    #[test]
1299    fn a_load_stretched_budget_is_shown_with_its_load() {
1300        let mut r = row("pre-push-run-tests-js", 240.0);
1301        r.quiet = 130.0;
1302        r.still = 130.0;
1303        r.cpu = RowCpu::Idle;
1304        r.load = Some((
1305            468,
1306            crate::load::Load {
1307                avg1_milli: 31_200,
1308                cores: 8,
1309            },
1310            3900,
1311        ));
1312        let text = region(&[r.clone()], 80, B);
1313        assert!(text.contains("· quiet 2m10s/7m48s (load ×3.9)"), "{text:?}");
1314        assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1315        let beat = beat_line(&r, false, B, false);
1316        assert!(
1317            beat.ends_with(
1318                ", CPU idle 2m10s, budget 7m48s (load avg 31.2 on 8 cores, ×3.9 — amont.idleLoadScale)\n"
1319            ),
1320            "{beat:?}"
1321        );
1322        let first = flat(&beat_line(&r, true, B, false));
1323        assert!(
1324            first.contains("killed after 7m48s with no output and under 0.1 core of CPU"),
1325            "{first:?}"
1326        );
1327    }
1328
1329    const B: Budgets = Budgets {
1330        idle: 120,
1331        ceiling: 3600,
1332        lock_wait: 600,
1333        extended: 480,
1334    };
1335
1336    /// While CPU is unmeasured the extended budget is the one that counts:
1337    /// the region counts toward it and says why, the heartbeat names it,
1338    /// and the first beat states that rule rather than the configured one.
1339    #[test]
1340    fn an_unmeasured_check_is_shown_against_the_extended_budget() {
1341        let mut r = row("pre-push-run-tests-js", 240.0);
1342        r.quiet = 130.0;
1343        r.still = 130.0;
1344        r.cpu = RowCpu::Unmeasured;
1345        let text = region(&[r.clone()], 80, B);
1346        assert!(
1347            text.contains("· quiet 2m10s/8m00s (CPU unmeasured)"),
1348            "{text:?}"
1349        );
1350        assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1351        let beat = beat_line(&r, false, B, false);
1352        assert!(
1353            beat.ends_with(", CPU unmeasured — extended budget 8m00s\n"),
1354            "{beat:?}"
1355        );
1356        let first = flat(&beat_line(&r, true, B, false));
1357        assert!(
1358            first.contains(
1359                "killed after 8m00s with no output while its CPU is unmeasured, or 1h00m in total"
1360            ),
1361            "{first:?}"
1362        );
1363        assert!(first.contains("amont.idleLoadScale"), "{first:?}");
1364    }
1365
1366    /// A declared wait replaces the silence countdown in the region and
1367    /// rides after the heartbeat's unchanged prefix, naming its budget; both
1368    /// fit 80 columns with the longest built-in name.
1369    #[test]
1370    fn a_declared_wait_is_shown_against_its_own_budget() {
1371        use crate::hooks::wait::{CargoLockWhat, WaitKind};
1372        let mut r = row("pre-push-run-tests-js", 240.0);
1373        r.quiet = 130.0;
1374        r.still = 0.0;
1375        r.cpu = RowCpu::Unmeasured;
1376        r.wait = Some((WaitKind::CargoLock(CargoLockWhat::BuildDirectory), 90.0));
1377        let text = region(&[r.clone()], 80, B);
1378        assert!(text.contains("· cargo lock 1m30s/10m00s"), "{text:?}");
1379        assert!(!text.contains("quiet"), "no silence countdown: {text:?}");
1380        assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1381        let until = Budgets { lock_wait: 0, ..B };
1382        let text = region(&[r.clone()], 80, until);
1383        assert!(
1384            text.contains("· cargo lock 1m30s") && !text.contains("1m30s/"),
1385            "{text:?}"
1386        );
1387
1388        let beat = beat_line(&r, false, B, false);
1389        assert_eq!(
1390            beat,
1391            "  … pre-push-run-tests-js still running: 4m00s, last output 2m10s ago, \
1392             waiting for the cargo lock on the build directory 1m30s (amont.lockWait 10m00s)\n"
1393        );
1394        let beat = beat_line(&r, false, until, false);
1395        assert!(
1396            beat.contains("(amont.lockWait 0: until the ceiling)"),
1397            "{beat:?}"
1398        );
1399        // The first beat states the wait's rule, not the CPU one: the
1400        // silence clock is not running.
1401        let first = flat(&beat_line(&r, true, B, false));
1402        assert!(
1403            first.contains("(killed after 10m00s in the wait, or 1h00m in total — amont.lockWait"),
1404            "{first:?}"
1405        );
1406        assert!(!first.contains("unmeasured"), "{first:?}");
1407    }
1408
1409    /// The spinner frame comes from the clock: different elapsed, different
1410    /// frame; same elapsed, same frame.
1411    #[test]
1412    fn frames_advance_with_time() {
1413        let a = region(&[row("clippy", 0.0)], 80, B);
1414        let b = region(&[row("clippy", 0.1)], 80, B);
1415        let c = region(&[row("clippy", 1.0)], 80, B);
1416        assert_ne!(a.chars().next(), b.chars().next());
1417        assert_eq!(a.chars().next(), c.chars().next(), "10 frames per second");
1418    }
1419
1420    /// Names pad to a column so the elapsed figures align — across the
1421    /// minute mark too, where the figure changes shape.
1422    #[test]
1423    fn region_lines_align() {
1424        let text = region(&[row("a", 0.0), row("longer-name", 0.0)], 80, B);
1425        let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
1426        assert_eq!(widths[0], widths[1], "{text:?}");
1427        let text = region(&[row("a", 3.2), row("b", 492.0)], 80, B);
1428        let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
1429        assert_eq!(widths[0], widths[1], "{text:?}");
1430        assert!(text.contains("8m12s"), "{text:?}");
1431    }
1432
1433    /// Thirteen running checks paint as twelve lines and one overflow.
1434    #[test]
1435    fn region_caps_and_counts_the_rest() {
1436        let entries: Vec<Row> = (0..13).map(|i| row(&format!("check-{i}"), 0.0)).collect();
1437        let text = region(&entries, 80, B);
1438        assert_eq!(text.lines().count(), MAX_LINES + 1);
1439        assert!(text.ends_with("… and 1 more\n"), "{text:?}");
1440    }
1441
1442    /// A narrow terminal truncates rather than wraps — a wrapped region
1443    /// line would break the erase arithmetic.
1444    #[test]
1445    fn region_respects_width() {
1446        let text = region(&[row("a-name-much-longer-than-the-terminal", 0.0)], 20, B);
1447        assert!(text.lines().all(|l| l.chars().count() <= 20), "{text:?}");
1448    }
1449
1450    /// No running checks, no region — not even a blank line.
1451    #[test]
1452    fn an_empty_region_is_empty() {
1453        assert_eq!(region(&[], 80, B), "");
1454    }
1455
1456    /// Silence is annotated only once it is news, and names the budget it
1457    /// counts toward — a check that just paused between crates says
1458    /// nothing extra.
1459    #[test]
1460    fn a_quiet_check_shows_its_silence_against_the_budget() {
1461        let mut r = row("cargo-test", 300.0);
1462        r.quiet = 5.0;
1463        assert!(!region(&[r.clone()], 80, B).contains("quiet"));
1464        r.quiet = 45.0;
1465        let text = region(&[r.clone()], 80, B);
1466        assert!(text.contains("quiet 45s/2m00s"), "{text:?}");
1467        let off = Budgets { idle: 0, ..B };
1468        let text = region(&[r], 80, off);
1469        assert!(
1470            text.contains("quiet 45s") && !text.contains('/'),
1471            "{text:?}"
1472        );
1473    }
1474
1475    /// A silent check that is working shows how hard, with no countdown —
1476    /// no kill is coming; one whose CPU work pushed the still-time back
1477    /// counts down the still-time, which is what the kill decision uses.
1478    /// Both fit an 80-column terminal with a longish name.
1479    #[test]
1480    fn a_quiet_busy_check_shows_cores_and_an_idle_one_counts_down_the_still_time() {
1481        let mut r = row("vitest-workspace", 240.0);
1482        r.quiet = 130.0;
1483        r.still = 130.0;
1484        r.cpu = RowCpu::Busy(3900);
1485        let busy = region(&[r.clone()], 80, B);
1486        assert!(busy.contains("· quiet 2m10s · ~3.9 cores"), "{busy:?}");
1487        assert!(
1488            !busy.contains("/2m00s"),
1489            "no countdown while busy: {busy:?}"
1490        );
1491
1492        r.cpu = RowCpu::Idle;
1493        r.still = 40.0;
1494        let idle = region(&[r.clone()], 80, B);
1495        assert!(idle.contains("· quiet 2m10s · idle 40s/2m00s"), "{idle:?}");
1496
1497        r.cpu = RowCpu::Unmeasured;
1498        r.still = 130.0;
1499        let plain = region(&[r], 80, B);
1500        assert!(
1501            plain.contains("· quiet 2m10s/8m00s (CPU unmeasured)"),
1502            "{plain:?}"
1503        );
1504
1505        for text in [busy, idle, plain] {
1506            assert!(text.lines().all(|l| l.chars().count() <= 80), "{text:?}");
1507        }
1508    }
1509
1510    /// The heartbeat's prefix is unchanged — log readers grep it — and the
1511    /// CPU state rides after it.
1512    #[test]
1513    fn a_heartbeat_appends_the_cpu_state_after_an_unchanged_prefix() {
1514        let mut r = row("vitest", 240.0);
1515        r.quiet = 130.0;
1516        r.still = 40.0;
1517        let prefix = "  … vitest still running: 4m00s, last output 2m10s ago";
1518        for (cpu, suffix) in [
1519            (RowCpu::Busy(3900), ", busy ~3.9 cores\n"),
1520            (RowCpu::Idle, ", CPU idle 40s\n"),
1521            (
1522                RowCpu::Unmeasured,
1523                ", CPU unmeasured — extended budget 8m00s\n",
1524            ),
1525            (RowCpu::None, "\n"),
1526            (RowCpu::NotSampled, "\n"),
1527        ] {
1528            r.cpu = cpu;
1529            assert_eq!(beat_line(&r, false, B, false), format!("{prefix}{suffix}"));
1530        }
1531    }
1532
1533    /// The first beat states the rule that actually applies to this check.
1534    #[test]
1535    fn the_first_beat_states_the_rule_in_force() {
1536        let mut r = row("vitest", 60.0);
1537        r.cpu = RowCpu::Idle;
1538        let sampled = beat_line(&r, true, B, false);
1539        assert!(
1540            flat(&sampled).contains(
1541                "killed after 2m00s with no output and under 0.1 core of CPU, or 1h00m in total"
1542            ),
1543            "{sampled:?}"
1544        );
1545        r.cpu = RowCpu::NotSampled;
1546        let not = beat_line(&r, true, B, false);
1547        assert!(
1548            not.contains("2m00s of silence or 1h00m in total"),
1549            "{not:?}"
1550        );
1551        assert!(
1552            not.contains("CPU not sampled here, silence alone counts"),
1553            "{not:?}"
1554        );
1555    }
1556
1557    /// The ceiling appears once a check is 80% of the way to it — the cliff,
1558    /// shown before the fall — and never for a disabled ceiling.
1559    #[test]
1560    fn the_ceiling_shows_only_when_it_is_near() {
1561        assert!(!region(&[row("cargo-test", 1000.0)], 80, B).contains("/1h00m"));
1562        let text = region(&[row("cargo-test", 3000.0)], 80, B);
1563        assert!(text.contains("50m00s/1h00m"), "{text:?}");
1564        let off = Budgets { ceiling: 0, ..B };
1565        assert!(!region(&[row("cargo-test", 3000.0)], 80, off).contains("/"));
1566    }
1567
1568    /// The heartbeat says how long, how quiet, and — the first time — the
1569    /// budgets, so a reader at the far end of a pipe can tell "wait" from
1570    /// "kill it" without the docs.
1571    #[test]
1572    fn a_heartbeat_names_the_budgets_once() {
1573        let mut r = row("cargo-test", 60.0);
1574        r.quiet = 2.0;
1575        let first = beat_line(&r, true, B, false);
1576        assert!(
1577            first.contains("cargo-test still running: 1m00s"),
1578            "{first:?}"
1579        );
1580        assert!(first.contains("last output 2s ago"), "{first:?}");
1581        assert!(
1582            first.contains("2m00s of silence or 1h00m in total"),
1583            "{first:?}"
1584        );
1585        assert!(first.contains("amont.idleTimeout"), "{first:?}");
1586        let later = beat_line(&r, false, B, false);
1587        assert!(!later.contains("amont.idleTimeout"), "{later:?}");
1588        let off = beat_line(
1589            &r,
1590            true,
1591            Budgets {
1592                idle: 0,
1593                ceiling: 0,
1594                lock_wait: 0,
1595                extended: 0,
1596            },
1597            false,
1598        );
1599        assert!(off.contains("off of silence or off in total"), "{off:?}");
1600    }
1601
1602    /// The message, with newlines and indentation flattened.
1603    ///
1604    /// The note is wrapped for a terminal, so a literal substring can fall
1605    /// across a line break — asserting on `"may close it first"` failed for
1606    /// no better reason than that `may` ended a line. These tests are about
1607    /// what the message SAYS; re-wrapping it should not break them.
1608    fn flat(line: &str) -> String {
1609        line.split_whitespace().collect::<Vec<_>>().join(" ")
1610    }
1611
1612    /// A long PUSH gate is told what it is sitting on; a commit gate is not.
1613    ///
1614    /// The three negatives matter as much as the positive. Said on every
1615    /// beat it would be nagging; said at commit time it would be false —
1616    /// there is no connection open — and a future refactor that wires
1617    /// `on_push` to a constant would show up here and nowhere else.
1618    #[test]
1619    fn a_long_push_gate_is_told_what_it_is_sitting_on() {
1620        let r = row("cargo-test", 60.0);
1621
1622        let pushing = flat(&beat_line(&r, true, B, true));
1623        assert!(
1624            pushing.contains("A remote may close it first"),
1625            "{pushing:?}"
1626        );
1627        assert!(pushing.contains("Connection reset by peer"), "{pushing:?}");
1628        assert!(
1629            pushing.contains("Moving a gate entry earlier"),
1630            "{pushing:?}"
1631        );
1632
1633        // Once, not every minute.
1634        let later = flat(&beat_line(&r, false, B, true));
1635        assert!(!later.contains("close it first"), "{later:?}");
1636
1637        // Never at commit time: nothing is waiting on a socket there.
1638        let committing = flat(&beat_line(&r, true, B, false));
1639        assert!(!committing.contains("close it first"), "{committing:?}");
1640    }
1641
1642    /// The advice that does NOT appear, and must not come back.
1643    ///
1644    /// `ServerAliveInterval 60` is the obvious suggestion and it is wrong:
1645    /// it was already in force on the machine where this failure was
1646    /// diagnosed, and the remote reset the connection regardless. Telling
1647    /// every amont user to set it would be confident, actionable and
1648    /// useless. This test exists so that a future reader who has the same
1649    /// obvious idea meets an argument instead of a blank.
1650    #[test]
1651    fn the_push_note_does_not_recommend_ssh_keepalive() {
1652        let r = row("cargo-test", 60.0);
1653        let pushing = flat(&beat_line(&r, true, B, true));
1654        assert!(
1655            !pushing.contains("ServerAlive"),
1656            "keepalive was already on when this failed; recommending it \
1657             would be useless advice: {pushing:?}"
1658        );
1659        assert!(
1660            pushing.contains("ssh keepalive does not prevent this"),
1661            "say so, rather than leaving the reader to try it: {pushing:?}"
1662        );
1663    }
1664}