1use 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
27const ID_DISPLAY_LEN: usize = 20;
30
31struct ChannelReporter {
34 tx: mpsc::UnboundedSender<ProgressEvent>,
35}
36
37impl ProgressReporter for ChannelReporter {
38 fn report(&self, event: ProgressEvent) {
39 let _ = self.tx.send(event);
42 }
43}
44
45struct ActiveComponent {
48 unit: ProgressUnit,
49 fetched: u64,
50 total: Option<u64>,
51 bar: ProgressBar,
52}
53
54pub(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
73fn 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 _ => "[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
94fn 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 bytes_cached: 0,
127 bytes: comp.fetched,
128 bytes_total: comp.total.unwrap_or(0),
129 });
130 }
131 if !total_known {
134 bytes_total = 0;
135 }
136 (subtasks, bytes_fetched, bytes_total)
137}
138
139async 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 let mut steps_done: u64 = 0;
158 let mut steps_cached: u64 = 0;
159 let mut any_activity = false;
167
168 while let Some(event) = rx.recv().await {
169 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 _ => {}
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 bytes_cached: 0,
245 bytes,
246 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 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 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 drop(reporter);
367 handle.await.expect("progress task should not panic");
368 }
369
370 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 #[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 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 #[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 #[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}