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}