Skip to main content

objects/
progress.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Generic, near-zero-cost progress substrate.
3//!
4//! A [`Progress`] handle is a cheap-to-clone (`Arc`-bump) counter that any
5//! long-running operation can drive without knowing how — or whether — the
6//! progress is rendered. The rendering half lives behind the [`Sink`] trait,
7//! which higher layers (the CLI) implement to paint a terminal line. Domain
8//! crates only ever see the handle.
9//!
10//! # Zero-overhead null path
11//!
12//! The overwhelmingly common case is "no one is watching" (piped output,
13//! `--output json`, embedded library use). For that case a handle is built
14//! with [`Progress::null`], whose sink slot is `None`. Every hot-path call
15//! ([`Progress::inc`]) then costs exactly one relaxed atomic add plus a single
16//! predicted-not-taken branch on the sink slot — no snapshot allocation, no
17//! syscall, and no virtual `Sink::render` dispatch. The vtable is only touched
18//! when a sink is actually installed via [`Progress::with_sink`].
19//!
20//! Throttling (redraw at most every N ticks) is deliberately *not* done here:
21//! [`Progress::inc`] always calls `render` when active, and the [`Sink`]
22//! decides whether to actually repaint. Keeping the decision in the renderer
23//! keeps `inc` branch-predictable and lets each sink pick its own cadence.
24
25use std::sync::{
26    Arc, Mutex,
27    atomic::{AtomicUsize, Ordering},
28};
29
30/// A point-in-time view of a [`Progress`] handle, handed to [`Sink::render`].
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ProgressSnapshot {
33    /// Units completed so far.
34    pub done: usize,
35    /// Total units of work, or `0` when the total is not yet known.
36    pub total: usize,
37    /// Current human phase label (e.g. `"importing commits"`).
38    pub phase: String,
39}
40
41/// Renders progress snapshots. Implemented by the presentation layer (the CLI's
42/// `TerminalSink`), never by domain crates.
43///
44/// A `Sink` is called on every active [`Progress::inc`], so implementations are
45/// responsible for their own throttling — cheap sinks may repaint every call,
46/// terminal sinks should coalesce redraws.
47pub trait Sink: Send + Sync {
48    /// Present the given snapshot. Called on the thread that drove the update;
49    /// may be called concurrently from multiple threads, so implementations
50    /// must be `Sync` and manage their own interior state.
51    fn render(&self, snap: ProgressSnapshot);
52}
53
54struct ProgressInner {
55    done: AtomicUsize,
56    total: AtomicUsize,
57    /// Phase changes are rare (once per operation stage), so a `Mutex` here
58    /// keeps the hot [`Progress::inc`] path lock-free while still letting
59    /// [`Progress::set_phase`] mutate the label.
60    phase: Mutex<String>,
61    /// `None` is the null path: no boxed sink, no snapshot, no vtable dispatch.
62    sink: Option<Box<dyn Sink>>,
63}
64
65/// A cheap-to-clone progress handle.
66///
67/// Cloning bumps an `Arc` refcount; all clones share the same counters and
68/// sink, so a handle can be threaded through `thread::scope` closures or
69/// stored alongside other shared state. `Send + Sync`.
70#[derive(Clone)]
71pub struct Progress(Arc<ProgressInner>);
72
73impl Progress {
74    /// A handle that renders nothing. The hot path is a relaxed add plus a
75    /// predicted-not-taken branch — see the module docs.
76    pub fn null() -> Self {
77        Progress(Arc::new(ProgressInner {
78            done: AtomicUsize::new(0),
79            total: AtomicUsize::new(0),
80            phase: Mutex::new(String::new()),
81            sink: None,
82        }))
83    }
84
85    /// A handle backed by a real [`Sink`]. Every active `inc` will call
86    /// `sink.render`; the sink is responsible for throttling.
87    pub fn with_sink(sink: Box<dyn Sink>) -> Self {
88        Progress(Arc::new(ProgressInner {
89            done: AtomicUsize::new(0),
90            total: AtomicUsize::new(0),
91            phase: Mutex::new(String::new()),
92            sink: Some(sink),
93        }))
94    }
95
96    /// Whether this handle renders. `false` for [`Progress::null`].
97    #[inline]
98    pub fn is_active(&self) -> bool {
99        self.0.sink.is_some()
100    }
101
102    /// Set the total unit count. Cheap; does not trigger a render on its own.
103    pub fn set_total(&self, total: usize) {
104        self.0.total.store(total, Ordering::Relaxed);
105    }
106
107    /// The current completed count.
108    #[inline]
109    pub fn done(&self) -> usize {
110        self.0.done.load(Ordering::Relaxed)
111    }
112
113    /// The current total (`0` if unknown).
114    #[inline]
115    pub fn total(&self) -> usize {
116        self.0.total.load(Ordering::Relaxed)
117    }
118
119    /// Set the human phase label. Rare relative to `inc`; when active it also
120    /// triggers a render so the label change is painted immediately (a
121    /// terminal sink typically forces a repaint on phase change).
122    pub fn set_phase(&self, label: impl Into<String>) {
123        let label = label.into();
124        {
125            let mut guard = self.lock_phase();
126            *guard = label;
127        }
128        if self.0.sink.is_some() {
129            self.render_current();
130        }
131    }
132
133    /// The current phase label.
134    pub fn phase(&self) -> String {
135        self.lock_phase().clone()
136    }
137
138    /// Advance the completed count by `n` and, if active, render.
139    ///
140    /// Hot path: `done.fetch_add(n, Relaxed)` then one branch on the optional
141    /// sink. When inactive nothing else happens — no snapshot, no vtable call.
142    #[inline]
143    pub fn inc(&self, n: usize) {
144        self.0.done.fetch_add(n, Ordering::Relaxed);
145        if self.0.sink.is_some() {
146            self.render_current();
147        }
148    }
149
150    /// Snapshot the current state and hand it to the sink. Cold relative to the
151    /// `active` check in `inc`, so it stays out-of-line.
152    fn render_current(&self) {
153        if let Some(sink) = &self.0.sink {
154            let snap = ProgressSnapshot {
155                done: self.0.done.load(Ordering::Relaxed),
156                total: self.0.total.load(Ordering::Relaxed),
157                phase: self.lock_phase().clone(),
158            };
159            sink.render(snap);
160        }
161    }
162
163    /// Take a current snapshot without rendering. Useful for a sink that wants
164    /// to force a final repaint (e.g. a "done" line).
165    pub fn snapshot(&self) -> ProgressSnapshot {
166        ProgressSnapshot {
167            done: self.0.done.load(Ordering::Relaxed),
168            total: self.0.total.load(Ordering::Relaxed),
169            phase: self.lock_phase().clone(),
170        }
171    }
172
173    fn lock_phase(&self) -> std::sync::MutexGuard<'_, String> {
174        self.0.phase.lock().unwrap_or_else(|p| p.into_inner())
175    }
176}
177
178impl std::fmt::Debug for Progress {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        f.debug_struct("Progress")
181            .field("done", &self.done())
182            .field("total", &self.total())
183            .field("active", &self.is_active())
184            .finish_non_exhaustive()
185    }
186}
187
188impl Default for Progress {
189    fn default() -> Self {
190        Self::null()
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use std::sync::atomic::{AtomicUsize, Ordering};
197
198    use super::*;
199
200    /// Adapter so a test can keep an `Arc<T>` to inspect its sink while the
201    /// [`Progress`] handle owns the `Box<dyn Sink>`. Renders forward to the
202    /// shared `T`.
203    struct Shared<T: Sink>(Arc<T>);
204    impl<T: Sink> Sink for Shared<T> {
205        fn render(&self, snap: ProgressSnapshot) {
206            self.0.render(snap);
207        }
208    }
209    fn progress_over<T: Sink + 'static>(sink: &Arc<T>) -> Progress {
210        Progress::with_sink(Box::new(Shared(Arc::clone(sink))))
211    }
212
213    /// Sink that records every snapshot it is handed.
214    #[derive(Default)]
215    struct CapturingSink {
216        renders: Mutex<Vec<ProgressSnapshot>>,
217        calls: AtomicUsize,
218    }
219    impl CapturingSink {
220        fn snapshots(&self) -> Vec<ProgressSnapshot> {
221            self.renders.lock().unwrap().clone()
222        }
223        fn call_count(&self) -> usize {
224            self.calls.load(Ordering::Relaxed)
225        }
226    }
227    impl Sink for CapturingSink {
228        fn render(&self, snap: ProgressSnapshot) {
229            self.calls.fetch_add(1, Ordering::Relaxed);
230            self.renders.lock().unwrap().push(snap);
231        }
232    }
233
234    #[test]
235    fn null_path_renders_nothing_under_a_tight_loop() {
236        // `null()` installs no sink. A tight `inc` loop must only advance the
237        // counter and hit the predicted-not-taken sink branch — no render, no
238        // panic. This is the smoke test for the null hot path; the real
239        // guarantee is the single `self.0.sink.is_some()` gate in `inc`.
240        let p = Progress::null();
241        assert!(!p.is_active());
242        for _ in 0..1_000_000 {
243            p.inc(1);
244        }
245        assert_eq!(p.done(), 1_000_000);
246        assert_eq!(p.total(), 0);
247    }
248
249    #[test]
250    fn inactive_handle_never_dispatches_render() {
251        // A null handle stays silent even through set_phase/set_total, which on
252        // an active handle would render. The absence of a sink is the sole gate.
253        let p = Progress::null();
254        p.set_total(50);
255        p.set_phase("noise");
256        p.inc(10);
257        assert!(!p.is_active());
258        assert_eq!(p.done(), 10);
259        assert_eq!(p.total(), 50);
260        assert_eq!(p.phase(), "noise");
261    }
262
263    #[test]
264    fn inc_and_set_total_track_counters() {
265        let sink = Arc::new(CapturingSink::default());
266        let p = progress_over(&sink);
267        p.set_total(128);
268        p.inc(1);
269        p.inc(3);
270        assert_eq!(p.done(), 4);
271        assert_eq!(p.total(), 128);
272        // Each active inc rendered exactly once (set_total does not render).
273        assert_eq!(sink.call_count(), 2);
274        let snaps = sink.snapshots();
275        assert_eq!(snaps.last().unwrap().done, 4);
276        assert_eq!(snaps.last().unwrap().total, 128);
277    }
278
279    #[test]
280    fn set_phase_is_captured_and_renders() {
281        let sink = Arc::new(CapturingSink::default());
282        let p = progress_over(&sink);
283        p.set_phase("scanning refs");
284        p.inc(1);
285        p.set_phase("writing refs");
286        assert_eq!(p.phase(), "writing refs");
287        let snaps = sink.snapshots();
288        // set_phase -> render, inc -> render, set_phase -> render
289        assert_eq!(snaps.len(), 3);
290        assert_eq!(snaps[0].phase, "scanning refs");
291        assert_eq!(snaps[1].phase, "scanning refs");
292        assert_eq!(snaps[2].phase, "writing refs");
293    }
294
295    #[test]
296    fn a_throttling_sink_sees_every_call_and_decides_itself() {
297        // The substrate hands the sink every active tick; throttling is the
298        // sink's job (COMMIT_TICK_INTERVAL lives in the renderer, not in `inc`).
299        const INTERVAL: usize = 64;
300        #[derive(Default)]
301        struct ThrottlingSink {
302            seen: AtomicUsize,
303            painted: AtomicUsize,
304        }
305        impl Sink for ThrottlingSink {
306            fn render(&self, snap: ProgressSnapshot) {
307                self.seen.fetch_add(1, Ordering::Relaxed);
308                if snap.done.is_multiple_of(INTERVAL) {
309                    self.painted.fetch_add(1, Ordering::Relaxed);
310                }
311            }
312        }
313        let sink = Arc::new(ThrottlingSink::default());
314        let p = progress_over(&sink);
315        for _ in 0..256 {
316            p.inc(1);
317        }
318        // Offered every active tick...
319        assert_eq!(sink.seen.load(Ordering::Relaxed), 256);
320        // ...but only "painted" on the throttle boundary (64,128,192,256).
321        assert_eq!(sink.painted.load(Ordering::Relaxed), 4);
322    }
323
324    #[test]
325    fn clone_shares_counters() {
326        let sink = Arc::new(CapturingSink::default());
327        let p = progress_over(&sink);
328        let q = p.clone();
329        p.inc(2);
330        q.inc(3);
331        assert_eq!(p.done(), 5);
332        assert_eq!(q.done(), 5);
333    }
334}