Skip to main content

bootc_lib/bootc_composefs/
progress.rs

1//! Bridge composefs-rs's [`ProgressReporter`] callback API to bootc's own
2//! progress infrastructure (interactive `indicatif` bars and the
3//! `--progress-fd` JSON-Lines protocol).
4//!
5//! composefs-rs (`composefs::progress`) reports progress via a synchronous,
6//! `Send + Sync` callback trait invoked directly from whatever task is
7//! driving the pull. That's a poor fit for [`crate::progress_jsonl::ProgressWriter`],
8//! whose API is `async`. We bridge the two by handing composefs-rs a trivial
9//! reporter that forwards every [`ProgressEvent`] over an unbounded channel,
10//! and processing that channel from a concurrently spawned Tokio task which
11//! owns the `indicatif` state and the `ProgressWriter`. This mirrors the
12//! existing ostree pull progress plumbing in `crate::deploy` (see
13//! `handle_layer_progress_print`), which is channel-based for the same
14//! reason.
15
16use std::collections::HashMap;
17
18use composefs_ctl::composefs::progress::{
19    ComponentId, ProgressEvent, ProgressReporter, ProgressUnit, SharedReporter,
20};
21use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
22use tokio::sync::mpsc;
23use tokio::task::JoinHandle;
24
25use crate::progress_jsonl::{Event, ProgressWriter, SubTaskBytes};
26
27/// Number of leading characters of a [`ComponentId`] (typically a
28/// `sha256:`-prefixed layer digest) to show in terminal output.
29const ID_DISPLAY_LEN: usize = 20;
30
31/// Forwards [`ProgressEvent`]s from composefs-rs's synchronous callback onto
32/// an unbounded channel for asynchronous processing.
33struct ChannelReporter {
34    tx: mpsc::UnboundedSender<ProgressEvent>,
35}
36
37impl ProgressReporter for ChannelReporter {
38    fn report(&self, event: ProgressEvent) {
39        // Errors mean the receiving task has already exited (e.g. the pull
40        // was aborted); there's nothing useful to do at that point.
41        let _ = self.tx.send(event);
42    }
43}
44
45/// State tracked per in-flight component so we can render both an
46/// `indicatif` bar and, for byte-oriented transfers, a JSON-Lines subtask.
47struct ActiveComponent {
48    unit: ProgressUnit,
49    fetched: u64,
50    total: Option<u64>,
51    bar: ProgressBar,
52}
53
54/// Start bridging composefs-rs progress events into bootc's UI.
55///
56/// Returns a [`SharedReporter`] to pass as `PullOptions::progress`, and the
57/// [`JoinHandle`] for the background task driving the UI. The task exits
58/// once every clone of the returned reporter has been dropped (which
59/// happens naturally when the `composefs_oci::pull` future completes and
60/// drops its `PullOptions`); callers should `.await` the join handle after
61/// awaiting the pull to ensure the terminal output is flushed before
62/// proceeding, and to recover the (possibly-mutated) `ProgressWriter`.
63pub(crate) fn spawn(
64    quiet: bool,
65    prog: ProgressWriter,
66) -> (SharedReporter, JoinHandle<ProgressWriter>) {
67    let (tx, rx) = mpsc::unbounded_channel();
68    let reporter: SharedReporter = std::sync::Arc::new(ChannelReporter { tx });
69    let handle = tokio::spawn(drive_progress(rx, quiet, prog));
70    (reporter, handle)
71}
72
73/// Truncate a [`ComponentId`] for compact display.
74fn short_id(id: &ComponentId) -> String {
75    let s = id.as_str();
76    s.chars().take(ID_DISPLAY_LEN).collect()
77}
78
79fn bar_style(unit: ProgressUnit) -> ProgressStyle {
80    let template = match unit {
81        ProgressUnit::Bytes => {
82            "[eta {eta}] {bar:40.cyan/blue} {binary_bytes:>9}/{binary_total_bytes:9} {msg}"
83        }
84        ProgressUnit::Items => "[eta {eta}] {bar:40.cyan/blue} {pos:>7}/{len:7} objects {msg}",
85        // `ProgressUnit` is `#[non_exhaustive]`; fall back to a generic style
86        // for any future variant.
87        _ => "[eta {eta}] {bar:40.cyan/blue} {pos}/{len} {msg}",
88    };
89    ProgressStyle::with_template(template)
90        .unwrap_or_else(|_| ProgressStyle::default_bar())
91        .progress_chars("##-")
92}
93
94/// Rebuild the JSON-Lines subtask list from the currently in-flight
95/// byte-oriented components (composefs-rs does not expose object-count
96/// progress in a form the `ProgressBytes` schema can represent, so
97/// [`ProgressUnit::Items`] components only drive the terminal UI).
98fn json_subtasks<'a>(
99    active: &'a HashMap<ComponentId, ActiveComponent>,
100) -> (Vec<SubTaskBytes<'a>>, u64, u64) {
101    let mut bytes_fetched = 0u64;
102    let mut bytes_total = 0u64;
103    let mut total_known = true;
104    let mut subtasks = Vec::new();
105    for (id, comp) in active {
106        if comp.unit != ProgressUnit::Bytes {
107            continue;
108        }
109        bytes_fetched = bytes_fetched.saturating_add(comp.fetched);
110        match comp.total {
111            Some(total) => bytes_total = bytes_total.saturating_add(total),
112            None => total_known = false,
113        }
114        let label = short_id(id);
115        subtasks.push(SubTaskBytes {
116            subtask: "composefs_layer".into(),
117            description: format!("Layer: {label}").into(),
118            id: id.as_str().into(),
119            // Unlike `steps_cached` below, there's no equivalent byte count
120            // available here: `ProgressEvent::Skipped` (composefs-rs's signal
121            // for an already-present component) carries only a `ComponentId`,
122            // not a size, even though callers generally know the size at the
123            // point they emit it (e.g. from the OCI manifest descriptor).
124            // Until composefs-rs's `Skipped` event carries a `total`, we have
125            // no way to attribute cached bytes to a specific subtask.
126            bytes_cached: 0,
127            bytes: comp.fetched,
128            bytes_total: comp.total.unwrap_or(0),
129        });
130    }
131    // `bytes_total == 0` is the protocol's way of saying "unspecified"; only
132    // report a real aggregate when every in-flight component's size is known.
133    if !total_known {
134        bytes_total = 0;
135    }
136    (subtasks, bytes_fetched, bytes_total)
137}
138
139/// Background task consuming [`ProgressEvent`]s and updating both the
140/// interactive terminal display and the JSON-Lines progress writer.
141async fn drive_progress(
142    mut rx: mpsc::UnboundedReceiver<ProgressEvent>,
143    quiet: bool,
144    prog: ProgressWriter,
145) -> ProgressWriter {
146    let multi = MultiProgress::new();
147    if quiet {
148        multi.set_draw_target(ProgressDrawTarget::hidden());
149    }
150
151    let mut active: HashMap<ComponentId, ActiveComponent> = HashMap::new();
152    // Components actually downloaded this run, versus ones that were already
153    // present (`ProgressEvent::Skipped`) and thus required no network I/O.
154    // Kept separate to match the `steps`/`steps_cached` convention used by
155    // the ostree pull path (see `crate::deploy`): `steps` counts real work
156    // done now, `steps_cached` counts work a prior run already did.
157    let mut steps_done: u64 = 0;
158    let mut steps_cached: u64 = 0;
159    // Whether any component has actually started, finished, or been skipped
160    // yet. Used instead of `!subtasks.is_empty()` to decide whether to emit
161    // a JSON-Lines update: gating on "a `Bytes`-unit component is currently
162    // active" would both drop the final update once the last component
163    // completes and `active` drains back to empty, and suppress every
164    // update during a purely `Items`-unit pull (e.g. a containers-storage
165    // zero-copy import), which never populates `subtasks` at all.
166    let mut any_activity = false;
167
168    while let Some(event) = rx.recv().await {
169        // `Done`/`Skipped` are discrete, one-shot milestones (a component's
170        // step count changes exactly once), unlike the continuous `Progress`
171        // ticks within a single component's transfer. Send those via the
172        // non-lossy `ProgressWriter::send`, matching the convention already
173        // used for the equivalent ostree layer-completion event in
174        // `crate::deploy` ("Cannot be lossy or it is dropped"): `send_lossy`
175        // silently drops updates that land within its refresh window, which
176        // would otherwise risk losing the final `steps`/`steps_cached` tally
177        // when components complete in a tight burst.
178        let mut required = false;
179
180        match event {
181            ProgressEvent::Started { id, total, unit } => {
182                let bar = if let Some(total) = total {
183                    multi.add(ProgressBar::new(total))
184                } else {
185                    multi.add(ProgressBar::new_spinner())
186                };
187                bar.set_style(bar_style(unit));
188                bar.set_message(short_id(&id));
189                active.insert(
190                    id,
191                    ActiveComponent {
192                        unit,
193                        fetched: 0,
194                        total,
195                        bar,
196                    },
197                );
198                any_activity = true;
199            }
200            ProgressEvent::Progress { id, fetched, total } => {
201                if let Some(comp) = active.get_mut(&id) {
202                    if let Some(total) = total {
203                        comp.bar.set_length(total);
204                        comp.total = Some(total);
205                    }
206                    comp.bar.set_position(fetched);
207                    comp.fetched = fetched;
208                }
209            }
210            ProgressEvent::Done { id, transferred } => {
211                if let Some(comp) = active.remove(&id) {
212                    comp.bar.finish_and_clear();
213                    let _ = transferred;
214                }
215                steps_done = steps_done.saturating_add(1);
216                any_activity = true;
217                required = true;
218            }
219            ProgressEvent::Skipped { id } => {
220                if let Some(comp) = active.remove(&id) {
221                    comp.bar.finish_with_message("skipped");
222                }
223                steps_cached = steps_cached.saturating_add(1);
224                any_activity = true;
225                required = true;
226            }
227            ProgressEvent::Message(msg) => {
228                let _ = multi.println(msg);
229            }
230            // `ProgressEvent` is `#[non_exhaustive]`; ignore future variants
231            // rather than failing to compile against newer composefs-rs.
232            _ => {}
233        }
234
235        let (subtasks, bytes, bytes_total) = json_subtasks(&active);
236        let event = Event::ProgressBytes {
237            task: "pulling".into(),
238            description: "Pulling composefs image".into(),
239            id: "composefs-pull".into(),
240            // See the comment on `bytes_cached` in `json_subtasks`: unlike
241            // `steps_cached`, composefs-rs gives us no way to know how
242            // many bytes an already-present (`Skipped`) component would
243            // have been, so this can't be anything but 0 for now.
244            bytes_cached: 0,
245            bytes,
246            // Total across all in-flight components is inherently a
247            // moving target since composefs-rs does not report an
248            // upfront count of components; report 0 ("unspecified")
249            // unless every in-flight component has a known size.
250            bytes_total,
251            steps_cached,
252            steps: steps_done,
253            steps_total: 0,
254            subtasks,
255        };
256        if required {
257            prog.send(event).await;
258        } else if any_activity {
259            prog.send_lossy(event).await;
260        }
261    }
262
263    prog
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    fn active_component(unit: ProgressUnit, fetched: u64, total: Option<u64>) -> ActiveComponent {
271        ActiveComponent {
272            unit,
273            fetched,
274            total,
275            bar: ProgressBar::hidden(),
276        }
277    }
278
279    #[test]
280    fn test_short_id_truncates_long_ids() {
281        let id: ComponentId =
282            "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".into();
283        let short = short_id(&id);
284        assert_eq!(short.chars().count(), ID_DISPLAY_LEN);
285        assert!(id.as_str().starts_with(&short));
286    }
287
288    #[test]
289    fn test_short_id_passes_through_short_ids() {
290        let id: ComponentId = "obj-1".into();
291        assert_eq!(short_id(&id), "obj-1");
292    }
293
294    #[test]
295    fn test_json_subtasks_ignores_items_unit() {
296        let mut active = HashMap::new();
297        active.insert(
298            ComponentId::from("objects"),
299            active_component(ProgressUnit::Items, 5, Some(10)),
300        );
301        let (subtasks, bytes, bytes_total) = json_subtasks(&active);
302        assert!(
303            subtasks.is_empty(),
304            "Items-unit components have no bytes subtask"
305        );
306        assert_eq!(bytes, 0);
307        assert_eq!(bytes_total, 0);
308    }
309
310    #[test]
311    fn test_json_subtasks_aggregates_known_totals() {
312        let mut active = HashMap::new();
313        active.insert(
314            ComponentId::from("layer-a"),
315            active_component(ProgressUnit::Bytes, 100, Some(200)),
316        );
317        active.insert(
318            ComponentId::from("layer-b"),
319            active_component(ProgressUnit::Bytes, 50, Some(300)),
320        );
321        let (subtasks, bytes, bytes_total) = json_subtasks(&active);
322        assert_eq!(subtasks.len(), 2);
323        assert_eq!(bytes, 150);
324        assert_eq!(bytes_total, 500);
325    }
326
327    #[test]
328    fn test_json_subtasks_unknown_total_is_unspecified() {
329        let mut active = HashMap::new();
330        active.insert(
331            ComponentId::from("layer-a"),
332            active_component(ProgressUnit::Bytes, 100, Some(200)),
333        );
334        active.insert(
335            ComponentId::from("layer-b"),
336            // Total isn't known yet for this component.
337            active_component(ProgressUnit::Bytes, 10, None),
338        );
339        let (subtasks, bytes, bytes_total) = json_subtasks(&active);
340        assert_eq!(subtasks.len(), 2);
341        assert_eq!(bytes, 110);
342        // Since one component's total is unknown, the aggregate must be
343        // reported as unspecified (0) rather than an understated value.
344        assert_eq!(bytes_total, 0);
345    }
346
347    #[tokio::test]
348    async fn test_drive_progress_runs_to_completion_on_channel_close() {
349        let (reporter, handle) = spawn(true, ProgressWriter::default());
350        reporter.report(ProgressEvent::Started {
351            id: "layer-a".into(),
352            total: Some(100),
353            unit: ProgressUnit::Bytes,
354        });
355        reporter.report(ProgressEvent::Progress {
356            id: "layer-a".into(),
357            fetched: 100,
358            total: Some(100),
359        });
360        reporter.report(ProgressEvent::Done {
361            id: "layer-a".into(),
362            transferred: 100,
363        });
364        reporter.report(ProgressEvent::Message("done".into()));
365        // Dropping the reporter closes the channel, letting the task exit.
366        drop(reporter);
367        handle.await.expect("progress task should not panic");
368    }
369
370    /// Reads every `ProgressBytes` event from a [`ProgressWriter`] pipe until
371    /// EOF, returning `(steps, steps_cached, subtasks.len())` for each one in
372    /// the order received.
373    async fn collect_progress_bytes(
374        recv: tokio::net::unix::pipe::Receiver,
375    ) -> Vec<(u64, u64, usize)> {
376        use tokio::io::{AsyncBufReadExt, BufReader};
377
378        let mut lines = BufReader::new(recv).lines();
379        let mut events = Vec::new();
380        while let Some(line) = lines.next_line().await.expect("read line") {
381            if let Ok(crate::progress_jsonl::Event::ProgressBytes {
382                steps,
383                steps_cached,
384                subtasks,
385                ..
386            }) = serde_json::from_str(&line)
387            {
388                events.push((steps, steps_cached, subtasks.len()));
389            }
390        }
391        events
392    }
393
394    /// `Done` (actually downloaded) and `Skipped` (already present) events
395    /// must be tallied into `steps`/`steps_cached` separately rather than a
396    /// single shared counter, matching the convention used by the ostree
397    /// pull path (`crate::deploy`). Both are sent via the non-lossy
398    /// `ProgressWriter::send`, so no delay is needed between them to dodge
399    /// `send_lossy`'s rate limiting.
400    #[tokio::test]
401    async fn test_drive_progress_reports_steps_cached_separately() {
402        let (send, recv) = tokio::net::unix::pipe::pipe().expect("create pipe");
403        let prog: ProgressWriter = send.try_into().expect("ProgressWriter from pipe");
404        let (reporter, handle) = spawn(true, prog);
405
406        reporter.report(ProgressEvent::Started {
407            id: "layer-fetched".into(),
408            total: Some(100),
409            unit: ProgressUnit::Bytes,
410        });
411        reporter.report(ProgressEvent::Done {
412            id: "layer-fetched".into(),
413            transferred: 100,
414        });
415        reporter.report(ProgressEvent::Skipped {
416            id: "layer-cached".into(),
417        });
418        drop(reporter);
419        handle.await.expect("progress task should not panic");
420
421        // The last event reflects the final tally: one component was
422        // actually fetched (Done) and one was already cached (Skipped).
423        let events = collect_progress_bytes(recv).await;
424        let &(steps, steps_cached, _) = events.last().expect("at least one event observed");
425        assert_eq!(steps, 1, "one component was actually downloaded");
426        assert_eq!(steps_cached, 1, "one component was already cached");
427    }
428
429    /// Once the last in-flight component finishes, `active` drains back to
430    /// empty and `json_subtasks` has nothing left to report — but the final
431    /// `steps`/`steps_cached` tally must still be flushed rather than
432    /// silently dropped just because there's no `Bytes`-unit subtask left to
433    /// show alongside it.
434    #[tokio::test]
435    async fn test_drive_progress_flushes_final_event_after_last_component_finishes() {
436        let (send, recv) = tokio::net::unix::pipe::pipe().expect("create pipe");
437        let prog: ProgressWriter = send.try_into().expect("ProgressWriter from pipe");
438        let (reporter, handle) = spawn(true, prog);
439
440        reporter.report(ProgressEvent::Started {
441            id: "layer-a".into(),
442            total: Some(100),
443            unit: ProgressUnit::Bytes,
444        });
445        reporter.report(ProgressEvent::Done {
446            id: "layer-a".into(),
447            transferred: 100,
448        });
449        drop(reporter);
450        handle.await.expect("progress task should not panic");
451
452        let events = collect_progress_bytes(recv).await;
453        let &(steps, _, subtasks_len) = events.last().expect("at least one event observed");
454        assert_eq!(steps, 1, "the completed component must still be counted");
455        assert_eq!(
456            subtasks_len, 0,
457            "no components remain in flight to report as subtasks"
458        );
459    }
460
461    /// A pull consisting solely of [`ProgressUnit::Items`] components (e.g. a
462    /// containers-storage zero-copy import) never populates `json_subtasks`,
463    /// since that unit has no `SubTaskBytes` representation. Progress must
464    /// still be reported at the aggregate `steps`/`steps_cached` level rather
465    /// than emitting nothing at all for the whole pull.
466    #[tokio::test]
467    async fn test_drive_progress_reports_steps_for_items_only_pull() {
468        let (send, recv) = tokio::net::unix::pipe::pipe().expect("create pipe");
469        let prog: ProgressWriter = send.try_into().expect("ProgressWriter from pipe");
470        let (reporter, handle) = spawn(true, prog);
471
472        reporter.report(ProgressEvent::Started {
473            id: "objects".into(),
474            total: Some(10),
475            unit: ProgressUnit::Items,
476        });
477        reporter.report(ProgressEvent::Done {
478            id: "objects".into(),
479            transferred: 10,
480        });
481        drop(reporter);
482        handle.await.expect("progress task should not panic");
483
484        let events = collect_progress_bytes(recv).await;
485        let &(steps, _, _) = events
486            .last()
487            .expect("an Items-only pull must still emit progress events");
488        assert_eq!(steps, 1);
489    }
490}