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