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    buf: Vec<u8>,
65    /// Entered and not yet finished — the region shows exactly these.
66    running: bool,
67    done: bool,
68}
69
70/// A running stage: the slots, and the one lock every terminal write inside
71/// the stage goes through.
72pub struct Stage {
73    slots: Mutex<Vec<Slot>>,
74    /// Serialises block emission and region repaints; the value is how many
75    /// region lines are currently painted (what an erase must remove).
76    out: Mutex<usize>,
77    /// Painting at all? [`enabled`] && [`watching`], decided once at begin.
78    live: bool,
79    stop: AtomicBool,
80}
81
82thread_local! {
83    /// Where [`say`] routes on THIS thread: a stage and a slot index.
84    static SINK: RefCell<Option<(Arc<Stage>, usize)>> = const { RefCell::new(None) };
85}
86
87impl Stage {
88    /// A stage over `names`, in dispatch order. Does nothing visible until
89    /// checks start entering (the region) or finishing (the blocks).
90    pub fn begin(names: &[&str]) -> Arc<Stage> {
91        let now = Instant::now();
92        let stage = Arc::new(Stage {
93            slots: Mutex::new(
94                names
95                    .iter()
96                    .map(|n| Slot {
97                        // Every name in a stage carries the stage's own
98                        // prefix ("pre-commit-clippy"); the region drops it
99                        // — twelve identical prefixes say nothing.
100                        name: crate::ui::sanitize(
101                            n.strip_prefix("pre-commit-")
102                                .or_else(|| n.strip_prefix("pre-push-"))
103                                .unwrap_or(n),
104                        ),
105                        started: now,
106                        buf: Vec::new(),
107                        running: false,
108                        done: false,
109                    })
110                    .collect(),
111            ),
112            out: Mutex::new(0),
113            live: enabled() && watching(),
114            stop: AtomicBool::new(false),
115        });
116        if stage.live {
117            // The ticker holds a Weak: the stage dropping is what ends it,
118            // so a paint can never outlive the region's owner.
119            let weak = Arc::downgrade(&stage);
120            let _ = std::thread::Builder::new()
121                .name("amont-live".into())
122                .spawn(move || tick(weak));
123        }
124        stage
125    }
126
127    /// Route this thread's [`say`] calls into slot `idx` until the guard
128    /// drops. Installed by the dispatcher around each `check.run`. Also
129    /// starts the slot's clock and puts it in the region.
130    pub fn enter(self: &Arc<Stage>, idx: usize) -> SinkGuard {
131        {
132            let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
133            if let Some(slot) = slots.get_mut(idx) {
134                slot.running = true;
135                slot.started = Instant::now();
136            }
137        }
138        SINK.with(|s| *s.borrow_mut() = Some((Arc::clone(self), idx)));
139        SinkGuard
140    }
141
142    /// Append raw bytes (a captured child's output) to slot `idx`.
143    pub fn append_raw(&self, idx: usize, bytes: &[u8]) {
144        let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
145        if let Some(slot) = slots.get_mut(idx) {
146            if !slot.done {
147                slot.buf.extend_from_slice(bytes);
148            }
149        }
150    }
151
152    fn append_line(&self, idx: usize, line: &str) {
153        let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
154        if let Some(slot) = slots.get_mut(idx) {
155            if !slot.done {
156                slot.buf.extend_from_slice(line.as_bytes());
157                slot.buf.push(b'\n');
158            }
159        }
160    }
161
162    /// The check is over: emit everything it said as ONE contiguous write,
163    /// with the region lifted out of the way first and repainted after —
164    /// blocks pile up above, spinners stay below.
165    ///
166    /// Called by the dispatcher after `check.run` returns (still on the
167    /// check's thread, so a torn-down thread cannot strand a buffer — the
168    /// same `catch_unwind` that feeds the dead-check outcome runs first).
169    pub fn finish(&self, idx: usize) {
170        let block = {
171            let mut slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
172            let Some(slot) = slots.get_mut(idx) else {
173                return;
174            };
175            slot.done = true;
176            slot.running = false;
177            std::mem::take(&mut slot.buf)
178        };
179        if block.is_empty() && !self.live {
180            return;
181        }
182        let mut drawn = self.out.lock().unwrap_or_else(|p| p.into_inner());
183        if !block.is_empty() {
184            if *drawn > 0 {
185                let mut err = std::io::stderr().lock();
186                let _ = write!(err, "\x1b[{}A\x1b[J", *drawn);
187                let _ = err.flush();
188                *drawn = 0;
189            }
190            let stdout = std::io::stdout();
191            let mut handle = stdout.lock();
192            let _ = handle.write_all(&block);
193            let _ = handle.flush();
194        }
195        self.repaint(&mut drawn);
196    }
197
198    /// Erase and redraw the region in one stderr write. Lock order is
199    /// `out` → `slots`, everywhere — never the reverse.
200    fn repaint(&self, drawn: &mut usize) {
201        if !self.live {
202            return;
203        }
204        let entries: Vec<(String, f64)> = {
205            let slots = self.slots.lock().unwrap_or_else(|p| p.into_inner());
206            let now = Instant::now();
207            slots
208                .iter()
209                .filter(|s| s.running && !s.done)
210                .map(|s| (s.name.clone(), now.duration_since(s.started).as_secs_f64()))
211                .collect()
212        };
213        let text = region(&entries, term_width());
214        let mut paint = String::new();
215        if *drawn > 0 {
216            paint.push_str(&format!("\x1b[{}A\x1b[J", *drawn));
217        }
218        paint.push_str(&text);
219        if paint.is_empty() {
220            return;
221        }
222        let mut err = std::io::stderr().lock();
223        let _ = err.write_all(paint.as_bytes());
224        let _ = err.flush();
225        *drawn = text.matches('\n').count();
226    }
227}
228
229impl Drop for Stage {
230    /// The stage's end erases whatever the region still shows — a Block
231    /// verdict, a panic on the dispatcher path, anything: no spinner junk
232    /// above the roll-up. (`get_mut`: dropping proves no other thread holds
233    /// the stage, so the locks are free.)
234    fn drop(&mut self) {
235        self.stop.store(true, Ordering::Relaxed);
236        if !self.live {
237            return;
238        }
239        let drawn = self.out.get_mut().unwrap_or_else(|p| p.into_inner());
240        if *drawn > 0 {
241            let mut err = std::io::stderr().lock();
242            let _ = write!(err, "\x1b[{}A\x1b[J", *drawn);
243            let _ = err.flush();
244            *drawn = 0;
245        }
246    }
247}
248
249/// The ticker: repaint every 80ms until the stage drops or tells it to
250/// stop. Holds only a `Weak`, so it can never keep a finished stage alive.
251fn tick(weak: Weak<Stage>) {
252    loop {
253        std::thread::sleep(std::time::Duration::from_millis(80));
254        let Some(stage) = weak.upgrade() else { return };
255        if stage.stop.load(Ordering::Relaxed) {
256            return;
257        }
258        let mut drawn = stage.out.lock().unwrap_or_else(|p| p.into_inner());
259        stage.repaint(&mut drawn);
260    }
261}
262
263/// The region's text: one `⠹ name  12.3s` line per running check, capped at
264/// [`MAX_LINES`] plus a `… and N more` overflow line. Pure — the ticker is
265/// a thin shell around this, and the tests drive it directly.
266fn region(entries: &[(String, f64)], width: usize) -> String {
267    if entries.is_empty() {
268        return String::new();
269    }
270    let pad = entries
271        .iter()
272        .take(MAX_LINES)
273        .map(|(name, _)| name.chars().count())
274        .max()
275        .unwrap_or(0);
276    let mut out = String::new();
277    for (name, secs) in entries.iter().take(MAX_LINES) {
278        let frame = FRAMES[((secs * 10.0) as usize) % FRAMES.len()];
279        let line = format!("{frame} {name:<pad$} {secs:>5.1}s");
280        if line.chars().count() > width {
281            out.extend(line.chars().take(width));
282        } else {
283            out.push_str(&line);
284        }
285        out.push('\n');
286    }
287    if entries.len() > MAX_LINES {
288        out.push_str(&format!("… and {} more\n", entries.len() - MAX_LINES));
289    }
290    out
291}
292
293/// `$COLUMNS` when it is exported and sane, else a conservative 100 — the
294/// region's lines are short and an ioctl is not worth its portability.
295fn term_width() -> usize {
296    std::env::var("COLUMNS")
297        .ok()
298        .and_then(|c| c.parse::<usize>().ok())
299        .filter(|w| *w >= 20)
300        .unwrap_or(100)
301}
302
303/// Emits slot `idx`'s block when dropped — however the check's closure
304/// exits, a panic included: the partial output of a check that died still
305/// reaches the reader, above the dead-check verdict the runner fills in.
306pub struct FinishOnDrop<'a> {
307    stage: &'a Stage,
308    idx: usize,
309}
310
311impl<'a> FinishOnDrop<'a> {
312    pub fn new(stage: &'a Stage, idx: usize) -> FinishOnDrop<'a> {
313        FinishOnDrop { stage, idx }
314    }
315}
316
317impl Drop for FinishOnDrop<'_> {
318    fn drop(&mut self) {
319        self.stage.finish(self.idx);
320    }
321}
322
323/// Uninstalls the thread's sink on drop, whatever path the check took out.
324pub struct SinkGuard;
325
326impl Drop for SinkGuard {
327    fn drop(&mut self) {
328        SINK.with(|s| *s.borrow_mut() = None);
329    }
330}
331
332/// The sink installed on THIS thread, if any — how a child-capture helper on
333/// the check's own thread learns where the reader threads should append.
334pub fn current_sink() -> Option<(Arc<Stage>, usize)> {
335    SINK.with(|s| s.borrow().clone())
336}
337
338/// One line of check output, wherever it should go.
339///
340/// THE funnel: `common::ok/fail/warn` call this, so a check's helper prints
341/// land in its slot during a stage and on stdout everywhere else. `line` is
342/// taken without a trailing newline, exactly like `println!`.
343pub fn say(line: &str) {
344    let routed = SINK.with(|s| {
345        s.borrow().as_ref().map(|(stage, idx)| {
346            stage.append_line(*idx, line);
347        })
348    });
349    if routed.is_none() {
350        println!("{line}");
351    }
352}
353
354/// `println!`, stage-aware: formats and routes through [`say`]. What every
355/// direct print inside a CHECK BODY becomes — a line printed raw from a
356/// check thread bypasses the slot and interleaves, which is the bug this
357/// module exists to close.
358#[macro_export]
359macro_rules! say {
360    ($($arg:tt)*) => {
361        $crate::live::say(&format!($($arg)*))
362    };
363}
364
365/// Whether the capture mechanism is on at all. `amont.progress false` is the
366/// escape hatch back to raw streaming — one knob, read once.
367pub fn enabled() -> bool {
368    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
369    *ENABLED.get_or_init(|| crate::config::boolean_or("amont.progress", true))
370}
371
372/// Is anyone watching? True only when stderr is a real terminal that speaks
373/// VT: not piped, not redirected, not `TERM=dumb` — and on Windows only
374/// with `TERM` actually set, because bare conhost may not interpret the
375/// cursor codes the region depends on. This is the paint gate; capture
376/// ([`enabled`]) does not consult it.
377pub fn watching() -> bool {
378    static WATCHING: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
379    *WATCHING.get_or_init(|| {
380        if !std::io::stderr().is_terminal() {
381            return false;
382        }
383        match std::env::var("TERM") {
384            Ok(term) => term != "dumb",
385            Err(_) => !cfg!(windows),
386        }
387    })
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    /// The atomicity contract at the unit level: two threads writing
395    /// interleaved lines into their own slots come out as two contiguous
396    /// buffers, whatever the scheduler did.
397    #[test]
398    fn slots_do_not_share_a_buffer() {
399        let stage = Stage::begin(&["a", "b"]);
400        std::thread::scope(|scope| {
401            for idx in 0..2 {
402                let stage = Arc::clone(&stage);
403                scope.spawn(move || {
404                    let _guard = stage.enter(idx);
405                    for i in 0..50 {
406                        say(&format!("check-{idx} line-{i}"));
407                        std::thread::yield_now();
408                    }
409                });
410            }
411        });
412        let slots = stage.slots.lock().unwrap();
413        for idx in 0..2 {
414            let text = String::from_utf8(slots[idx].buf.clone()).unwrap();
415            assert_eq!(text.lines().count(), 50);
416            assert!(
417                text.lines()
418                    .all(|l| l.starts_with(&format!("check-{idx} "))),
419                "a foreign line landed in slot {idx}"
420            );
421        }
422    }
423
424    /// A thread with no sink prints; its lines never land in anyone's slot.
425    #[test]
426    fn no_sink_means_no_capture() {
427        let stage = Stage::begin(&["a"]);
428        say("goes to stdout, not to a slot");
429        let slots = stage.slots.lock().unwrap();
430        assert!(slots[0].buf.is_empty());
431    }
432
433    /// After finish, late writes are dropped rather than stranded — a child
434    /// reader thread that outlives its check must not corrupt a later block.
435    #[test]
436    fn a_finished_slot_takes_no_more_writes() {
437        let stage = Stage::begin(&["a"]);
438        stage.append_raw(0, b"before\n");
439        stage.finish(0);
440        stage.append_raw(0, b"after\n");
441        let slots = stage.slots.lock().unwrap();
442        assert!(slots[0].buf.is_empty(), "a write landed after finish");
443    }
444
445    /// A repo-derived check name cannot smuggle control bytes onto a live
446    /// terminal: sanitised at begin, once, for every later paint.
447    #[test]
448    fn a_slot_name_is_sanitised_at_begin() {
449        let stage = Stage::begin(&["evil\u{1b}[2Jname\rhere"]);
450        let slots = stage.slots.lock().unwrap();
451        assert!(!slots[0].name.contains('\u{1b}'), "{:?}", slots[0].name);
452        assert!(!slots[0].name.contains('\r'), "{:?}", slots[0].name);
453    }
454
455    /// Region names drop the stage's own prefix — it is the same twelve
456    /// characters on every line.
457    #[test]
458    fn a_slot_name_drops_the_stage_prefix() {
459        let stage = Stage::begin(&["pre-commit-clippy", "pre-push-run-tests", "bare"]);
460        let slots = stage.slots.lock().unwrap();
461        assert_eq!(slots[0].name, "clippy");
462        assert_eq!(slots[1].name, "run-tests");
463        assert_eq!(slots[2].name, "bare");
464    }
465
466    /// The spinner frame comes from the clock: different elapsed, different
467    /// frame; same elapsed, same frame.
468    #[test]
469    fn frames_advance_with_time() {
470        let a = region(&[("clippy".into(), 0.0)], 80);
471        let b = region(&[("clippy".into(), 0.1)], 80);
472        let c = region(&[("clippy".into(), 1.0)], 80);
473        assert_ne!(a.chars().next(), b.chars().next());
474        assert_eq!(a.chars().next(), c.chars().next(), "10 frames per second");
475    }
476
477    /// Names pad to a column so the elapsed figures align.
478    #[test]
479    fn region_lines_align() {
480        let text = region(&[("a".into(), 0.0), ("longer-name".into(), 0.0)], 80);
481        let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
482        assert_eq!(widths[0], widths[1], "{text:?}");
483    }
484
485    /// Thirteen running checks paint as twelve lines and one overflow.
486    #[test]
487    fn region_caps_and_counts_the_rest() {
488        let entries: Vec<(String, f64)> = (0..13).map(|i| (format!("check-{i}"), 0.0)).collect();
489        let text = region(&entries, 80);
490        assert_eq!(text.lines().count(), MAX_LINES + 1);
491        assert!(text.ends_with("… and 1 more\n"), "{text:?}");
492    }
493
494    /// A narrow terminal truncates rather than wraps — a wrapped region
495    /// line would break the erase arithmetic.
496    #[test]
497    fn region_respects_width() {
498        let text = region(&[("a-name-much-longer-than-the-terminal".into(), 0.0)], 20);
499        assert!(text.lines().all(|l| l.chars().count() <= 20), "{text:?}");
500    }
501
502    /// No running checks, no region — not even a blank line.
503    #[test]
504    fn an_empty_region_is_empty() {
505        assert_eq!(region(&[], 80), "");
506    }
507}