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}