ktstr/cli/progress.rs
1//! Progress bars for kernel fetches (tarball download + git clone).
2//!
3//! Two bar types, both rendered through a single
4//! [`indicatif::MultiProgress`] so concurrent fetches (the parallel
5//! `cargo ktstr test --kernel A --kernel B` resolve) never garble
6//! each other's terminal output, and both degrading to a no-op when
7//! stderr is not a TTY (CI, piped output) — the same contract as
8//! [`crate::cli::Spinner`].
9//!
10//! - [`FetchProgress`] — the group handle. One per resolve operation,
11//! shared across the rayon workers by `&` (the inner
12//! [`indicatif::MultiProgress`] is `Send + Sync`). Hands out child
13//! bars and replaces the single-instance [`crate::cli::Spinner`] the
14//! download path previously wrapped (a `Spinner` cannot host
15//! concurrent bars). The build phase renders through this group too:
16//! `kernel_build_pipeline` takes a group and draws the configure /
17//! build / `compile_commands.json` phases as `step_bar` spinners. So
18//! no code path runs a standalone `Spinner` alongside a
19//! live `MultiProgress`, and the parallel resolve's concurrent builds
20//! share the one group rather than racing the process-global
21//! `SPINNER_ACTIVE` guard. The only remaining `Spinner` is
22//! `auto_download_kernel`'s brief version-fetch, which always finishes
23//! before any group bar is created.
24//! - [`GroupBar`] — a determinate byte bar for tarball downloads:
25//! transfer rate and ETA derived from `Content-Length`. Falls back
26//! to a live byte counter (rate, no ETA) when the response carries
27//! no `Content-Length`.
28//! - [`CloneProgress`] — a determinate object/file bar for git clones,
29//! driven by polling gix's prodash progress tree via
30//! `tree::Root::sorted_snapshot`. Shows a real bar + ETA whenever gix
31//! reports a bounded total — resolving deltas and checkout files
32//! always do; the receiving/read-pack phase does only when the server
33//! advertises a pack size (often unknown for shallow smart-HTTP
34//! clones) — and a spinner otherwise (negotiation, or an unadvertised
35//! pack size). Single renderer (indicatif), zero new dependencies —
36//! prodash's own line renderer is not compiled in this dependency
37//! graph, so the tree is read rather than rendered by prodash.
38
39use std::sync::Arc;
40use std::sync::atomic::{AtomicBool, Ordering};
41use std::thread::JoinHandle;
42use std::time::Duration;
43
44use gix::progress::prodash::progress::{Key, Task};
45use gix::progress::tree::{Item, Root};
46use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
47
48use crate::cli::stderr_color;
49
50/// Frame cadence shared by the bars' steady tick and the gix poll
51/// loop. The poll loop samples the prodash tree at this rate — a
52/// display ticker, not an evented wait: prodash exposes no
53/// "counter advanced" notification, only the pull-based
54/// `sorted_snapshot`, so a fixed-cadence sample (the same approach
55/// prodash's own renderer takes via `frames_per_second`) is the
56/// correct shape here.
57const TICK: Duration = Duration::from_millis(100);
58
59/// Determinate byte-download template: spinner, label, bar, bytes,
60/// rate, ETA. `bytes`/`total_bytes`/`bytes_per_sec` render in binary
61/// units (MiB/KiB); `eta` is `Content-Length`-derived.
62const DOWNLOAD_TEMPLATE: &str =
63 "{spinner:.green} {msg} [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})";
64
65/// Byte-counter template for a download with no `Content-Length`: no
66/// total means no percent/ETA, but a live byte count + rate still
67/// shows real movement.
68const DOWNLOAD_TEMPLATE_NO_TOTAL: &str = "{spinner:.green} {msg} {bytes} ({bytes_per_sec})";
69
70/// Label-only spinner template for a non-quantifiable phase (kernel
71/// configure / build / compile_commands generation) — a moving spinner
72/// plus a message, with no byte/percent/ETA fields (there is nothing to
73/// count). Mirrors the cyan spinner the old build `Spinner` used.
74const STEP_TEMPLATE: &str = "{spinner:.cyan} {msg}";
75
76/// Determinate clone template: spinner, label/phase, bar, count,
77/// percent, ETA. Driven by gix's bounded object/file counts; the ETA
78/// is derived by indicatif from the count rate.
79const CLONE_TEMPLATE: &str =
80 "{spinner:.green} {msg} [{wide_bar:.cyan/blue}] {pos}/{len} ({percent}%, {eta})";
81
82/// Spinner template for the brief negotiation window before any
83/// bounded gix task exists (no total ⇒ nothing to estimate).
84const CLONE_TEMPLATE_SPINNER: &str = "{spinner:.green} {msg}";
85
86/// Progress glyphs for the filled bar (filled, current, remaining).
87const PROGRESS_CHARS: &str = "##-";
88
89/// Shared owner of the concurrent fetch progress bars for one resolve
90/// operation.
91///
92/// Wraps an [`indicatif::MultiProgress`] (constructed against a hidden
93/// draw target off-TTY) and hands out child bars via `download_bar` /
94/// `clone_progress`. Created once per resolve and shared across the rayon
95/// `resolve_specs_parallel` workers by `&` reference — `MultiProgress`
96/// is `Send + Sync` and `add` is internally serialized on an
97/// `Arc<RwLock>`, so concurrent `download_bar`/`clone_progress` calls
98/// are safe.
99///
100/// Off-TTY (`!stderr_color()`) the group draws to a hidden target:
101/// every child bar is a no-op, `inc`/`finish` emit nothing, and the
102/// gix poll thread is never spawned — piped/CI stderr stays
103/// escape-free, matching the [`crate::cli::Spinner`] degradation
104/// contract.
105pub struct FetchProgress {
106 /// Hidden draw target off-TTY; stderr otherwise. Always present
107 /// (vs `Option`) so child-bar construction is unconditional and
108 /// the hidden/visible branch lives in one place: the draw target.
109 multi: MultiProgress,
110}
111
112impl FetchProgress {
113 /// Construct a fetch-progress group. On a TTY it renders to
114 /// stderr; otherwise (non-TTY / `NO_COLOR` / `TERM=dumb`) it uses
115 /// a hidden draw target so nothing reaches piped output.
116 ///
117 /// The `stderr_color()` gate mirrors [`crate::cli::Spinner`];
118 /// indicatif additionally auto-hides on a non-TTY, so the hidden
119 /// behavior is correct even if the two ever disagreed.
120 pub fn new() -> Self {
121 let multi = if stderr_color() {
122 MultiProgress::new()
123 } else {
124 MultiProgress::with_draw_target(ProgressDrawTarget::hidden())
125 };
126 Self { multi }
127 }
128
129 /// Whether the group draws to a hidden target (non-TTY / forced).
130 /// When hidden, [`Self::clone_progress`] skips its poll thread.
131 pub(crate) fn is_hidden(&self) -> bool {
132 self.multi.is_hidden()
133 }
134
135 /// Add a determinate byte-download bar. `total` is the response
136 /// `Content-Length`: `Some` ⇒ percent + ETA; `None` ⇒ a live byte
137 /// counter (rate, no ETA). `label` names the kernel being fetched
138 /// so concurrent bars are distinguishable. Off-TTY the returned
139 /// bar is hidden.
140 pub(crate) fn download_bar(&self, label: &str, total: Option<u64>) -> GroupBar {
141 let pb = match total {
142 Some(len) => ProgressBar::new(len),
143 None => ProgressBar::new_spinner(),
144 };
145 let pb = self.multi.add(pb);
146 let template = if total.is_some() {
147 DOWNLOAD_TEMPLATE
148 } else {
149 DOWNLOAD_TEMPLATE_NO_TOTAL
150 };
151 pb.set_style(
152 ProgressStyle::with_template(template)
153 .expect("valid download template")
154 .progress_chars(PROGRESS_CHARS),
155 );
156 pb.set_message(label.to_string());
157 // Skip the steady-tick ticker thread on the hidden path — it
158 // would redraw nothing yet still spawn a thread per bar (the
159 // exact CI/non-TTY case this module no-ops).
160 if !self.is_hidden() {
161 pb.enable_steady_tick(TICK);
162 }
163 GroupBar { pb }
164 }
165
166 /// Add a label-only spinner bar for a non-quantifiable build phase
167 /// (kernel configure / build / `compile_commands.json` generation).
168 /// Unlike [`Self::download_bar`] with `total = None`, it carries no
169 /// byte counter — the build phase has no bytes to count, only a
170 /// moving spinner and a message. Off-TTY it is hidden and the
171 /// steady-tick thread is skipped, matching [`Self::download_bar`].
172 ///
173 /// Routing the build phase through the group (rather than a
174 /// standalone [`crate::cli::Spinner`]) is what lets concurrent
175 /// builds in the parallel resolve render together without racing the
176 /// process-global `SPINNER_ACTIVE` guard.
177 pub(crate) fn step_bar(&self, label: &str) -> GroupBar {
178 let pb = self.multi.add(ProgressBar::new_spinner());
179 pb.set_style(ProgressStyle::with_template(STEP_TEMPLATE).expect("valid step template"));
180 pb.set_message(label.to_string());
181 if !self.is_hidden() {
182 pb.enable_steady_tick(TICK);
183 }
184 GroupBar { pb }
185 }
186
187 /// Add a git-clone progress bar and spawn the gix-tree → indicatif
188 /// poll bridge. `label` names the clone (its git ref). The bar is
189 /// determinate (with ETA) whenever gix reports a bounded task and
190 /// a spinner otherwise; see [`CloneProgress`].
191 ///
192 /// Off-TTY (`is_hidden()`), no poll thread is spawned — the
193 /// returned [`CloneProgress`] still yields a valid (no-op) gix
194 /// progress sink via [`CloneProgress::item`].
195 pub(crate) fn clone_progress(&self, label: &str) -> CloneProgress {
196 let root = Root::new();
197 let bar = self.multi.add(ProgressBar::new_spinner());
198 bar.set_style(
199 ProgressStyle::with_template(CLONE_TEMPLATE_SPINNER)
200 .expect("valid clone spinner template"),
201 );
202 bar.set_message(format!("cloning {label}"));
203
204 // Hidden: no rendering, so skip both the steady-tick ticker
205 // thread and the poll thread. The tree (root) still exists so
206 // `item()` hands gix a valid NestedProgress sink that simply
207 // goes unread.
208 if self.is_hidden() {
209 return CloneProgress {
210 root,
211 bar,
212 stop: Arc::new(AtomicBool::new(false)),
213 poller: None,
214 };
215 }
216
217 bar.enable_steady_tick(TICK);
218 let stop = Arc::new(AtomicBool::new(false));
219 let poller = std::thread::spawn({
220 let root = Arc::clone(&root);
221 let bar = bar.clone();
222 let stop = Arc::clone(&stop);
223 let label = label.to_string();
224 move || poll_clone_tree(root, bar, stop, label)
225 });
226 CloneProgress {
227 root,
228 bar,
229 stop,
230 poller: Some(poller),
231 }
232 }
233
234 /// Print a status line that coordinates with the live bars.
235 ///
236 /// On a visible group this routes through
237 /// [`indicatif::MultiProgress::println`] so the line lands above
238 /// the bars without garbling them (a raw `eprintln!` while bars
239 /// are drawing corrupts their cursor accounting). On a hidden
240 /// group (non-TTY / CI) `MultiProgress::println` is a no-op draw
241 /// that would *swallow* the line, so it falls back to `eprintln!`
242 /// to preserve the status output piped/CI consumers rely on.
243 ///
244 /// Best-effort: a `println` error (e.g. broken pipe) is discarded
245 /// — a status line must never abort a fetch.
246 pub(crate) fn println(&self, line: &str) {
247 if self.is_hidden() {
248 eprintln!("{line}");
249 } else {
250 let _ = self.multi.println(line);
251 }
252 }
253
254 /// Remove all bars from the group. Best-effort: a clear failure is
255 /// discarded so it can never mask the resolve result the caller is
256 /// returning.
257 pub fn clear(&self) {
258 let _ = self.multi.clear();
259 }
260}
261
262impl Default for FetchProgress {
263 fn default() -> Self {
264 Self::new()
265 }
266}
267
268/// Poll the gix prodash tree at [`TICK`] cadence and mirror the most
269/// relevant task onto `bar` until `stop` is set.
270///
271/// Picks the deepest task that has a *bounded* counter (`done_at =
272/// Some` — a known total) so the bar shows a real position/length and
273/// indicatif can compute an ETA. Resolving deltas and checkout files
274/// are always bounded; receiving/read-pack is bounded only when the
275/// server advertises a pack size. Falls back to the deepest live task's
276/// name (spinner only) when no bounded task exists yet (negotiation, or
277/// an unadvertised pack size). The bar style is switched only on a
278/// determinate/indeterminate transition, not every tick.
279fn poll_clone_tree(root: Arc<Root>, bar: ProgressBar, stop: Arc<AtomicBool>, label: String) {
280 let mut snapshot: Vec<(Key, Task)> = Vec::new();
281 let mut determinate = false;
282 while !stop.load(Ordering::Relaxed) {
283 poll_tick(&root, &bar, &mut determinate, &label, &mut snapshot);
284 std::thread::sleep(TICK);
285 }
286}
287
288/// One poll iteration: snapshot the tree and mirror the most relevant
289/// task onto `bar`. Extracted from [`poll_clone_tree`] so the
290/// snapshot→bar mapping is unit-testable without spawning a thread or
291/// running a real clone. `determinate` carries the determinate/spinner
292/// style state across calls so the style is switched only on a
293/// transition, not every tick. `snapshot` is a caller-owned scratch
294/// buffer reused across ticks to avoid a per-tick allocation.
295fn poll_tick(
296 root: &Root,
297 bar: &ProgressBar,
298 determinate: &mut bool,
299 label: &str,
300 snapshot: &mut Vec<(Key, Task)>,
301) {
302 root.sorted_snapshot(snapshot);
303
304 // Deepest bounded task (known total) if any; else deepest task with
305 // any live counter. `sorted_snapshot` orders by Key lexicographically
306 // (pre-order DFS over the tree); the clone is a single parent chain
307 // at any instant (root → "clone" → gix's active child), so the last
308 // matching entry is the deepest active task.
309 let bounded = snapshot
310 .iter()
311 .rev()
312 .find(|(_, t)| t.progress.as_ref().and_then(|v| v.done_at).is_some());
313 let any = snapshot.iter().rev().find(|(_, t)| t.progress.is_some());
314
315 if let Some((_, task)) = bounded {
316 // Safe: `bounded` only matched tasks whose progress is Some.
317 if let Some(value) = task.progress.as_ref() {
318 if !*determinate {
319 bar.set_style(
320 ProgressStyle::with_template(CLONE_TEMPLATE)
321 .expect("valid clone template")
322 .progress_chars(PROGRESS_CHARS),
323 );
324 *determinate = true;
325 }
326 bar.set_length(value.done_at.unwrap_or(0) as u64);
327 bar.set_position(value.step.load(Ordering::SeqCst) as u64);
328 bar.set_message(format!("{label}: {}", task.name));
329 }
330 } else {
331 // No bounded task: spinner mode. This covers negotiation, an
332 // unadvertised pack size, AND a no-counter task (prodash gives
333 // `progress = None` when init is called with neither a max nor a
334 // unit, e.g. gix's "negotiate") — in all of which `any` may be
335 // None. Flip the style back on the determinate→spinner
336 // transition regardless, so the bar never stays stuck showing a
337 // stale determinate length; surface the deepest live task's name
338 // when one exists.
339 if *determinate {
340 bar.set_style(
341 ProgressStyle::with_template(CLONE_TEMPLATE_SPINNER)
342 .expect("valid clone spinner template"),
343 );
344 *determinate = false;
345 }
346 if let Some((_, task)) = any {
347 bar.set_message(format!("{label}: {}", task.name));
348 }
349 }
350}
351
352/// A single bar within a [`FetchProgress`] group.
353///
354/// Backs two uses. A determinate (or byte-counter) download bar
355/// ([`FetchProgress::download_bar`]): its inner
356/// [`indicatif::ProgressBar`] is handed to
357/// `crate::fetch::DownloadStream::with_progress` via [`Self::bar`] and
358/// advanced with `inc(n)` beside the stream's own byte accounting — a
359/// single source of truth, so `position()` always equals the streamed
360/// `bytes_total`. And a label-only build-phase spinner
361/// ([`FetchProgress::step_bar`]) that only ever calls [`Self::finish`]
362/// (its [`Self::bar`] is unused — there is no stream to attach). Off-TTY
363/// the bar is hidden and every method is a no-op draw.
364pub(crate) struct GroupBar {
365 pb: ProgressBar,
366}
367
368impl GroupBar {
369 /// A clone of the inner bar to attach to the download stream
370 /// (download-bar use only). `ProgressBar` is `Arc`-backed, so the
371 /// clone drives the same bar.
372 pub(crate) fn bar(&self) -> ProgressBar {
373 self.pb.clone()
374 }
375
376 /// Finish and clear the bar. Idempotent.
377 pub(crate) fn finish(&self) {
378 self.pb.finish_and_clear();
379 }
380}
381
382impl Drop for GroupBar {
383 /// Clear the bar if it was not explicitly finished — covers the
384 /// download fns' extraction-error exit (bar already created, the
385 /// xz/gzip unpack fails via `?` before [`Self::finish`]) that
386 /// bails with a live, unfinished bar. HTTP failure and HTML reject
387 /// bail before the bar is constructed; a sha256 mismatch bails
388 /// after `finish()` has already run, so Drop is only a redundant
389 /// no-op there.
390 /// Idempotent with `finish`: a second `finish_and_clear` is a
391 /// no-op on an already-cleared bar.
392 fn drop(&mut self) {
393 self.pb.finish_and_clear();
394 }
395}
396
397/// Live git-clone progress bridge.
398///
399/// Holds the prodash [`tree::Root`](Root), the indicatif bar, and (on
400/// a TTY) the background poll thread spawned by
401/// [`FetchProgress::clone_progress`]. [`Self::item`] yields a fresh gix
402/// progress sink ([`tree::Item`](Item)) for each gix call; both the
403/// fetch and checkout phases feed the one polled tree.
404///
405/// Shutdown is leak-proof: [`Self::finish`] (success path) and the
406/// [`Drop`] impl (a `git_clone` that bails via `?`) both signal the
407/// poll thread to stop and join it, then clear the bar — never detached,
408/// never leaked. Under the release profile's `panic = "abort"`, `Drop`
409/// does not run on a panic, but the whole process aborts so the poll
410/// thread cannot outlive it either.
411pub(crate) struct CloneProgress {
412 /// The prodash progress tree gix writes into. Shared (`Arc`) with
413 /// the poll thread, which snapshots it.
414 root: Arc<Root>,
415 /// The indicatif bar the poll thread drives. Cleared on shutdown.
416 bar: ProgressBar,
417 /// Set by [`Self::shutdown`] to stop the poll loop.
418 stop: Arc<AtomicBool>,
419 /// The poll thread handle. `None` off-TTY (no thread spawned) and
420 /// after [`Self::shutdown`] takes it to join.
421 poller: Option<JoinHandle<()>>,
422}
423
424impl CloneProgress {
425 /// A fresh gix progress sink for one gix call. `git_clone` calls
426 /// this once for the fetch phase and once for checkout; both
427 /// children feed the single polled tree, so the one bar reflects
428 /// whichever phase is active.
429 pub(crate) fn item(&self) -> Item {
430 self.root.add_child("clone")
431 }
432
433 /// Stop the poll thread, join it, and clear the bar. Consuming
434 /// form for the success path; [`Drop`] performs the same work on
435 /// the error/panic path.
436 pub(crate) fn finish(mut self) {
437 self.shutdown();
438 }
439
440 /// Idempotent teardown: signal stop, join the poll thread (if any
441 /// — `Option::take` makes a second call a no-op), then clear the
442 /// bar. The join happens after both gix calls have returned, so
443 /// the poll thread is never holding the snapshot lock across it.
444 fn shutdown(&mut self) {
445 self.stop.store(true, Ordering::Relaxed);
446 if let Some(handle) = self.poller.take() {
447 let _ = handle.join();
448 }
449 self.bar.finish_and_clear();
450 }
451}
452
453impl Drop for CloneProgress {
454 fn drop(&mut self) {
455 self.shutdown();
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 /// A force-hidden group, independent of whether the test host has a
464 /// TTY. Constructs `FetchProgress` directly (white-box: the tests
465 /// are a child module of `progress`) so no production-only `hidden`
466 /// constructor is needed.
467 fn hidden_group() -> FetchProgress {
468 FetchProgress {
469 multi: MultiProgress::with_draw_target(ProgressDrawTarget::hidden()),
470 }
471 }
472
473 /// Under nextest, stderr is captured (not a TTY), so the default
474 /// group degrades to a hidden draw target — the arm CI runs.
475 #[test]
476 fn fetch_progress_new_is_hidden_under_non_tty() {
477 assert!(
478 FetchProgress::new().is_hidden(),
479 "a non-TTY FetchProgress must use a hidden draw target so piped/CI stderr stays clean",
480 );
481 }
482
483 /// A determinate download bar tracks `inc` exactly and exposes the
484 /// configured total — the contract `DownloadStream` relies on
485 /// (position advances by the bytes it streams).
486 #[test]
487 fn download_bar_determinate_tracks_inc() {
488 let fp = hidden_group();
489 let bar = fp.download_bar("6.14.2", Some(100));
490 bar.pb.inc(40);
491 bar.pb.inc(60);
492 assert_eq!(bar.pb.position(), 100);
493 assert_eq!(bar.pb.length(), Some(100));
494 }
495
496 /// With no Content-Length the bar is indeterminate (no total) but
497 /// still counts bytes — a live counter rather than a percent bar.
498 #[test]
499 fn download_bar_indeterminate_when_no_total() {
500 let fp = hidden_group();
501 let bar = fp.download_bar("6.15-rc3", None);
502 assert_eq!(bar.pb.length(), None);
503 bar.pb.inc(10);
504 assert_eq!(bar.pb.position(), 10);
505 }
506
507 /// `finish` is idempotent and the `Drop` impl also clears — neither
508 /// the explicit-finish nor the drop-without-finish path panics.
509 #[test]
510 fn download_bar_finish_and_drop_no_panic() {
511 let fp = hidden_group();
512 let bar = fp.download_bar("k", Some(10));
513 bar.finish();
514 bar.finish();
515 drop(bar);
516 // A bar dropped without an explicit finish (error path) clears
517 // via Drop without panic.
518 let bar2 = fp.download_bar("k2", Some(10));
519 bar2.pb.inc(5);
520 drop(bar2);
521 }
522
523 /// Adding and driving many bars concurrently from worker threads
524 /// (the rayon `resolve_specs_parallel` shape) must not panic or
525 /// deadlock — `MultiProgress` serializes `add` on its `RwLock`,
526 /// and `FetchProgress` is `Sync` so `&fp` shares across threads.
527 #[test]
528 fn fetch_progress_concurrent_bars_no_panic() {
529 let fp = hidden_group();
530 std::thread::scope(|s| {
531 for i in 0..16u32 {
532 let fp = &fp;
533 s.spawn(move || {
534 let bar = fp.download_bar(&format!("k{i}"), Some(50));
535 for _ in 0..50 {
536 bar.pb.inc(1);
537 }
538 assert_eq!(bar.pb.position(), 50, "each bar accounts independently");
539 bar.finish();
540 });
541 }
542 });
543 fp.clear();
544 }
545
546 /// A hidden group skips the poll thread entirely (nothing to
547 /// render), yet still yields a valid gix progress sink via
548 /// `item()`, and `finish` is clean.
549 #[test]
550 fn clone_progress_hidden_skips_poller() {
551 let fp = hidden_group();
552 let cp = fp.clone_progress("for-next");
553 assert!(
554 cp.poller.is_none(),
555 "hidden group must not spawn the poll thread",
556 );
557 let _item = cp.item();
558 cp.finish();
559 }
560
561 /// The poll thread shuts down and joins cleanly. Constructs a
562 /// `CloneProgress` over a real (unrendered) tree with the poller
563 /// running, then `finish` must stop + join within one tick without
564 /// hanging — pinning the leak-proof shutdown path.
565 #[test]
566 fn clone_progress_finish_joins_poll_thread() {
567 let root = Root::new();
568 let bar = ProgressBar::hidden();
569 let stop = Arc::new(AtomicBool::new(false));
570 let poller = std::thread::spawn({
571 let root = Arc::clone(&root);
572 let bar = bar.clone();
573 let stop = Arc::clone(&stop);
574 move || poll_clone_tree(root, bar, stop, "for-next".to_string())
575 });
576 let cp = CloneProgress {
577 root,
578 bar,
579 stop,
580 poller: Some(poller),
581 };
582 // Must not hang: sets stop, the loop exits within one TICK, join
583 // returns.
584 cp.finish();
585 }
586
587 /// A bounded gix task (known total) maps onto a determinate bar:
588 /// length = `done_at`, position = `step`. Deterministic — drives
589 /// the tree and runs one tick, no thread, no sleep.
590 #[test]
591 fn poll_tick_maps_bounded_task_to_determinate_bar() {
592 let root = Root::new();
593 let bar = ProgressBar::hidden();
594 let child = root.add_child("receiving objects");
595 child.init(Some(200), None);
596 child.set(80);
597
598 let mut determinate = false;
599 let mut snapshot = Vec::new();
600 poll_tick(&root, &bar, &mut determinate, "for-next", &mut snapshot);
601
602 assert!(
603 determinate,
604 "a bounded task must switch the bar to determinate"
605 );
606 assert_eq!(bar.length(), Some(200));
607 assert_eq!(bar.position(), 80);
608 }
609
610 /// An unbounded gix task (no total — e.g. the negotiation phase)
611 /// leaves the bar in spinner mode: `determinate` stays false and no
612 /// length is set.
613 #[test]
614 fn poll_tick_unbounded_task_stays_spinner() {
615 let root = Root::new();
616 let bar = ProgressBar::hidden();
617 let child = root.add_child("negotiate");
618 child.init(None, None);
619 child.set(3);
620
621 let mut determinate = false;
622 let mut snapshot = Vec::new();
623 poll_tick(&root, &bar, &mut determinate, "for-next", &mut snapshot);
624
625 assert!(
626 !determinate,
627 "an unbounded task must not flip the bar to determinate",
628 );
629 assert_eq!(bar.length(), None, "no total ⇒ no determinate length");
630 }
631
632 /// Once a bounded task has flipped the bar to determinate, a later
633 /// snapshot whose only live task is unbounded flips it back to
634 /// spinner mode — the bounded→unbounded transition branch in
635 /// `poll_tick` (gix can drop a bounded child before the next bounded
636 /// phase appears).
637 #[test]
638 fn poll_tick_flips_back_to_spinner_when_unbounded() {
639 let root = Root::new();
640 let bar = ProgressBar::hidden();
641 let mut snapshot = Vec::new();
642 let mut determinate = false;
643
644 let bounded = root.add_child("resolving deltas");
645 bounded.init(Some(50), None);
646 bounded.set(10);
647 poll_tick(&root, &bar, &mut determinate, "for-next", &mut snapshot);
648 assert!(determinate, "a bounded task must flip to determinate first");
649
650 // Drop the bounded task (gix removes a finished child from the
651 // tree) and leave only an unbounded one.
652 drop(bounded);
653 let unbounded = root.add_child("negotiate");
654 unbounded.init(None, None);
655 unbounded.set(1);
656 poll_tick(&root, &bar, &mut determinate, "for-next", &mut snapshot);
657 assert!(
658 !determinate,
659 "an unbounded-only snapshot must flip the bar back to spinner mode",
660 );
661 }
662
663 /// `println` on a hidden group selects the `eprintln!` arm (gated by
664 /// `is_hidden()`) rather than `multi.println` — which would swallow
665 /// the line on a hidden draw target — and does not panic. Pins
666 /// branch selection + no-panic; like the sibling `Spinner` tests it
667 /// does not capture stderr to assert the bytes are emitted (that is
668 /// std `eprintln!` behavior). The anti-swallow rationale is
669 /// documented on [`FetchProgress::println`] itself.
670 #[test]
671 fn fetch_progress_println_hidden_no_panic() {
672 let fp = hidden_group();
673 // is_hidden() == true selects the eprintln! arm inside println.
674 assert!(fp.is_hidden());
675 fp.println("cargo ktstr: downloading linux-6.14.2 (130.0 MiB)");
676 }
677
678 /// Several real poll threads created and shut down concurrently from
679 /// worker threads (the rayon multi-`git+URL` fan-out shape) must
680 /// neither panic nor hang — pins the concurrent clone-shutdown path
681 /// that the single-threaded `clone_progress_finish_joins_poll_thread`
682 /// test does not reach.
683 #[test]
684 fn concurrent_clone_progress_shutdown_no_panic() {
685 std::thread::scope(|s| {
686 for _ in 0..8 {
687 s.spawn(|| {
688 let root = Root::new();
689 let bar = ProgressBar::hidden();
690 let stop = Arc::new(AtomicBool::new(false));
691 let poller = std::thread::spawn({
692 let root = Arc::clone(&root);
693 let bar = bar.clone();
694 let stop = Arc::clone(&stop);
695 move || poll_clone_tree(root, bar, stop, "ref".to_string())
696 });
697 let cp = CloneProgress {
698 root,
699 bar,
700 stop,
701 poller: Some(poller),
702 };
703 let _ = cp.item();
704 cp.finish();
705 });
706 }
707 });
708 }
709
710 /// A `step_bar` is a label-only spinner: no byte counter (length is
711 /// `None`), and finish + drop-without-finish are both clean.
712 #[test]
713 fn step_bar_no_counter_no_panic() {
714 let fp = hidden_group();
715 let bar = fp.step_bar("Building kernel...");
716 assert_eq!(
717 bar.pb.length(),
718 None,
719 "a step bar has no quantifiable total"
720 );
721 bar.finish();
722 // Drop-without-finish (the build error path) must also be clean.
723 drop(fp.step_bar("Configuring kernel..."));
724 }
725
726 /// Many build-phase `step_bar`s driven concurrently on one group
727 /// (the parallel-resolve shape where multiple workers build at once)
728 /// must not panic. Direct regression pin for the old
729 /// concurrent-`Spinner` `SPINNER_ACTIVE` race that this change
730 /// removes by routing the build phase through the group instead of a
731 /// standalone `Spinner`.
732 #[test]
733 fn concurrent_step_bars_no_panic() {
734 let fp = hidden_group();
735 std::thread::scope(|s| {
736 for i in 0..16u32 {
737 let fp = &fp;
738 s.spawn(move || {
739 let bar = fp.step_bar(&format!("Building kernel {i}..."));
740 bar.finish();
741 });
742 }
743 });
744 fp.clear();
745 }
746}