1use std::collections::{HashMap, VecDeque};
11use std::sync::{Arc, Mutex as StdMutex};
12
13use mold_core::download::RecipeAuth;
14use mold_core::types::{DownloadEvent, DownloadJob, DownloadsListing, JobStatus};
15use tokio::sync::{broadcast, Mutex as AsyncMutex, Notify};
16use tokio_util::sync::CancellationToken;
17
18#[derive(Clone, Debug)]
22pub struct OwnedRecipeFile {
23 pub url: String,
24 pub dest: String,
25 pub sha256: Option<String>,
26 pub size_bytes: Option<u64>,
27}
28
29#[derive(Clone, Debug)]
34pub struct RecipePayload {
35 pub catalog_id: String,
38 pub files: Vec<OwnedRecipeFile>,
39 pub auth: RecipeAuth,
40}
41
42pub const EVENT_BUFFER: usize = 256;
45
46pub const HISTORY_CAP: usize = 20;
48
49pub const DOWNLOAD_CONCURRENCY: usize = 2;
52
53pub struct ActiveHandle {
55 pub job: DownloadJob,
56 pub abort: CancellationToken,
57}
58
59#[derive(Debug, Clone)]
71struct CatalogGroup {
72 catalog_id: String,
73 job_ids: std::collections::HashSet<String>,
75 outcomes: HashMap<String, JobStatus>,
78}
79
80pub struct DownloadQueue {
81 active: AsyncMutex<HashMap<String, ActiveHandle>>,
82 queued: StdMutex<VecDeque<DownloadJob>>,
83 history: StdMutex<VecDeque<DownloadJob>>,
84 events: broadcast::Sender<DownloadEvent>,
85 notify: Notify,
87 recipe_payloads: StdMutex<HashMap<String, RecipePayload>>,
91 groups: StdMutex<HashMap<String, CatalogGroup>>,
97}
98
99#[derive(Debug, thiserror::Error)]
100pub enum EnqueueError {
101 #[error("unknown model '{0}'. Run 'mold list' to see available models.")]
102 UnknownModel(String),
103 #[error("download queue lock poisoned")]
104 LockPoisoned,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum EnqueueOutcome {
109 AlreadyPresent,
111 Created,
113}
114
115impl DownloadQueue {
116 pub fn new() -> Arc<Self> {
117 let (events, _rx) = broadcast::channel(EVENT_BUFFER);
118 Arc::new(Self {
119 active: AsyncMutex::new(HashMap::new()),
120 queued: StdMutex::new(VecDeque::new()),
121 history: StdMutex::new(VecDeque::new()),
122 events,
123 notify: Notify::new(),
124 recipe_payloads: StdMutex::new(HashMap::new()),
125 groups: StdMutex::new(HashMap::new()),
126 })
127 }
128
129 #[cfg(test)]
132 pub fn new_for_test() -> Arc<Self> {
133 Self::new()
134 }
135
136 pub fn subscribe(&self) -> broadcast::Receiver<DownloadEvent> {
137 self.events.subscribe()
138 }
139
140 pub async fn listing(&self) -> DownloadsListing {
142 let mut active_jobs: Vec<DownloadJob> = self
143 .active
144 .lock()
145 .await
146 .values()
147 .map(|handle| handle.job.clone())
148 .collect();
149 active_jobs.sort_by_key(|job| (job.started_at, job.id.clone()));
150 let active = active_jobs.first().cloned();
151 let queued: Vec<DownloadJob> = self
152 .queued
153 .lock()
154 .expect("queued lock poisoned")
155 .iter()
156 .cloned()
157 .collect();
158 let history: Vec<DownloadJob> = self
159 .history
160 .lock()
161 .expect("history lock poisoned")
162 .iter()
163 .cloned()
164 .collect();
165 DownloadsListing {
166 active_jobs,
167 active,
168 queued,
169 history,
170 }
171 }
172
173 pub async fn enqueue(
177 &self,
178 model: String,
179 ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
180 let canonical = mold_core::manifest::resolve_model_name(&model);
183 if mold_core::manifest::find_manifest(&canonical).is_none() {
184 return Err(EnqueueError::UnknownModel(model));
185 }
186
187 {
189 let active = self.active.lock().await;
190 if let Some(handle) = active.values().find(|handle| handle.job.model == canonical) {
191 return Ok((handle.job.id.clone(), 0, EnqueueOutcome::AlreadyPresent));
192 }
193 }
194 {
195 let queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
196 if let Some((idx, existing)) = queued
197 .iter()
198 .enumerate()
199 .find(|(_, j)| j.model == canonical)
200 {
201 return Ok((existing.id.clone(), idx + 1, EnqueueOutcome::AlreadyPresent));
202 }
203 }
204
205 let id = uuid::Uuid::new_v4().to_string();
206 let job = DownloadJob {
207 id: id.clone(),
208 model: canonical.clone(),
209 catalog_id: None,
210 status: JobStatus::Queued,
211 files_done: 0,
212 files_total: 0,
213 bytes_done: 0,
214 bytes_total: 0,
215 current_file: None,
216 started_at: None,
217 completed_at: None,
218 error: None,
219 };
220
221 let position = {
222 let mut queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
223 queued.push_back(job);
224 queued.len() };
226 let _ = self.events.send(DownloadEvent::Enqueued {
227 id: id.clone(),
228 model: canonical,
229 position,
230 });
231 self.notify.notify_one();
232 Ok((id, position, EnqueueOutcome::Created))
233 }
234
235 pub async fn enqueue_recipe(
245 &self,
246 payload: RecipePayload,
247 ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
248 if payload.catalog_id.trim().is_empty() {
249 return Err(EnqueueError::UnknownModel(payload.catalog_id));
250 }
251
252 {
254 let active = self.active.lock().await;
255 if let Some(handle) = active
256 .values()
257 .find(|handle| handle.job.model == payload.catalog_id)
258 {
259 return Ok((handle.job.id.clone(), 0, EnqueueOutcome::AlreadyPresent));
260 }
261 }
262 {
263 let queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
264 if let Some((idx, existing)) = queued
265 .iter()
266 .enumerate()
267 .find(|(_, j)| j.model == payload.catalog_id)
268 {
269 return Ok((existing.id.clone(), idx + 1, EnqueueOutcome::AlreadyPresent));
270 }
271 }
272
273 let id = uuid::Uuid::new_v4().to_string();
274 let job = DownloadJob {
275 id: id.clone(),
276 model: payload.catalog_id.clone(),
277 catalog_id: Some(payload.catalog_id.clone()),
278 status: JobStatus::Queued,
279 files_done: 0,
280 files_total: payload.files.len(),
281 bytes_done: 0,
282 bytes_total: payload.files.iter().filter_map(|f| f.size_bytes).sum(),
283 current_file: None,
284 started_at: None,
285 completed_at: None,
286 error: None,
287 };
288
289 {
292 let mut payloads = self
293 .recipe_payloads
294 .lock()
295 .map_err(|_| EnqueueError::LockPoisoned)?;
296 payloads.insert(id.clone(), payload.clone());
297 }
298
299 let position = {
300 let mut queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
301 queued.push_back(job);
302 queued.len()
303 };
304 let _ = self.events.send(DownloadEvent::Enqueued {
305 id: id.clone(),
306 model: payload.catalog_id,
307 position,
308 });
309 self.notify.notify_one();
310 Ok((id, position, EnqueueOutcome::Created))
311 }
312
313 pub async fn enqueue_in_group(
319 &self,
320 model: String,
321 catalog_id: &str,
322 ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
323 let (id, pos, outcome) = self.enqueue(model).await?;
324 self.register_in_group(catalog_id, &id);
325 Ok((id, pos, outcome))
326 }
327
328 pub async fn enqueue_recipe_in_group(
331 &self,
332 payload: RecipePayload,
333 catalog_id: &str,
334 ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
335 let (id, pos, outcome) = self.enqueue_recipe(payload).await?;
336 self.register_in_group(catalog_id, &id);
337 Ok((id, pos, outcome))
338 }
339
340 fn register_in_group(&self, catalog_id: &str, job_id: &str) {
341 self.tag_job_with_catalog_id(catalog_id, job_id);
342 let mut groups = match self.groups.lock() {
343 Ok(g) => g,
344 Err(_) => return, };
346 let entry = groups
347 .entry(catalog_id.to_string())
348 .or_insert_with(|| CatalogGroup {
349 catalog_id: catalog_id.to_string(),
350 job_ids: std::collections::HashSet::new(),
351 outcomes: HashMap::new(),
352 });
353 entry.job_ids.insert(job_id.to_string());
354 }
355
356 fn tag_job_with_catalog_id(&self, catalog_id: &str, job_id: &str) {
357 if let Ok(mut queued) = self.queued.lock() {
358 if let Some(job) = queued.iter_mut().find(|job| job.id == job_id) {
359 job.catalog_id = Some(catalog_id.to_string());
360 return;
361 }
362 }
363 if let Ok(mut history) = self.history.lock() {
364 if let Some(job) = history.iter_mut().find(|job| job.id == job_id) {
365 job.catalog_id = Some(catalog_id.to_string());
366 return;
367 }
368 }
369 if let Ok(mut active) = self.active.try_lock() {
370 if let Some(handle) = active.get_mut(job_id) {
371 handle.job.catalog_id = Some(catalog_id.to_string());
372 }
373 }
374 }
375
376 pub(crate) fn settle_for_group(&self, job_id: &str, status: JobStatus) {
385 let to_emit = {
387 let mut groups = match self.groups.lock() {
388 Ok(g) => g,
389 Err(_) => return,
390 };
391 let mut emit_for: Option<(String, bool)> = None;
392 for group in groups.values_mut() {
397 if !group.job_ids.contains(job_id) {
398 continue;
399 }
400 group.outcomes.insert(job_id.to_string(), status);
401 if group.outcomes.len() == group.job_ids.len() {
402 let ok = group
403 .outcomes
404 .values()
405 .all(|s| matches!(s, JobStatus::Completed));
406 emit_for = Some((group.catalog_id.clone(), ok));
407 }
408 break; }
410 if let Some((id, _)) = &emit_for {
411 groups.remove(id);
412 }
413 emit_for
414 };
415 if let Some((id, ok)) = to_emit {
416 self.emit(DownloadEvent::CatalogReady { id, ok });
417 }
418 }
419
420 pub(crate) fn take_recipe_payload(&self, job_id: &str) -> Option<RecipePayload> {
424 self.recipe_payloads
425 .lock()
426 .ok()
427 .and_then(|mut p| p.remove(job_id))
428 }
429
430 pub async fn cancel(&self, id: &str) -> bool {
433 {
435 let mut queued = self.queued.lock().expect("queued lock poisoned");
436 if let Some(pos) = queued.iter().position(|j| j.id == id) {
437 let mut job = queued.remove(pos).expect("queued position must exist");
438 drop(queued);
439 job.status = JobStatus::Cancelled;
440 job.completed_at = Some(now_ms());
441 self.recipe_payloads
442 .lock()
443 .expect("recipe payload lock poisoned")
444 .remove(id);
445 self.push_history(job);
446 let _ = self
447 .events
448 .send(DownloadEvent::JobCancelled { id: id.to_string() });
449 let _ = self
450 .events
451 .send(DownloadEvent::Dequeued { id: id.to_string() });
452 self.settle_for_group(id, JobStatus::Cancelled);
453 return true;
454 }
455 }
456 let active = self.active.lock().await;
458 if let Some(handle) = active.get(id) {
459 handle.abort.cancel();
460 return true;
462 }
463 false
465 }
466
467 pub(crate) fn push_history(&self, job: DownloadJob) {
470 let mut history = self.history.lock().expect("history lock poisoned");
471 if history.len() >= HISTORY_CAP {
472 history.pop_front();
473 }
474 history.push_back(job);
475 }
476
477 pub(crate) async fn wait_for_work(&self, shutdown: &CancellationToken) {
479 tokio::select! {
480 _ = self.notify.notified() => {},
481 _ = shutdown.cancelled() => {},
482 }
483 }
484
485 pub(crate) fn take_next_queued(&self) -> Option<DownloadJob> {
486 self.queued
487 .lock()
488 .expect("queued lock poisoned")
489 .pop_front()
490 }
491
492 pub(crate) async fn set_active(&self, handle: ActiveHandle) {
493 self.active
494 .lock()
495 .await
496 .insert(handle.job.id.clone(), handle);
497 }
498
499 pub(crate) async fn clear_active(&self, id: &str) -> Option<ActiveHandle> {
500 self.active.lock().await.remove(id)
501 }
502
503 pub(crate) async fn with_active<F, R>(&self, id: &str, f: F) -> Option<R>
504 where
505 F: FnOnce(&mut DownloadJob) -> R,
506 {
507 let mut active = self.active.lock().await;
508 active.get_mut(id).map(|a| f(&mut a.job))
509 }
510
511 async fn cancel_all_active(&self) {
512 for handle in self.active.lock().await.values() {
513 handle.abort.cancel();
514 }
515 }
516
517 pub(crate) fn emit(&self, event: DownloadEvent) {
518 let _ = self.events.send(event);
519 }
520}
521
522#[async_trait::async_trait]
529pub trait PullDriver: Send + Sync + 'static {
530 async fn pull(
531 &self,
532 model: &str,
533 on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
534 cancel: CancellationToken,
535 ) -> Result<(), String>;
536}
537
538#[async_trait::async_trait]
543pub trait RecipePullDriver: Send + Sync + 'static {
544 async fn pull(
545 &self,
546 payload: RecipePayload,
547 models_dir: std::path::PathBuf,
548 on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
549 cancel: CancellationToken,
550 ) -> Result<(), String>;
551}
552
553pub struct CivitaiRecipeDriver;
555
556#[async_trait::async_trait]
557impl RecipePullDriver for CivitaiRecipeDriver {
558 async fn pull(
559 &self,
560 payload: RecipePayload,
561 models_dir: std::path::PathBuf,
562 on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
563 cancel: CancellationToken,
564 ) -> Result<(), String> {
565 let on_progress: mold_core::download::DownloadProgressCallback =
566 std::sync::Arc::from(on_progress);
567 let opts = mold_core::download::PullOptions::default();
568 let files: Vec<mold_core::download::RecipeFetchFile<'_>> = payload
569 .files
570 .iter()
571 .map(|f| mold_core::download::RecipeFetchFile {
572 url: f.url.as_str(),
573 dest: f.dest.as_str(),
574 sha256: f.sha256.as_deref(),
575 size_bytes: f.size_bytes,
576 })
577 .collect();
578 tokio::select! {
579 res = mold_core::download::fetch_recipe(
580 &payload.catalog_id,
581 &files,
582 payload.auth.clone(),
583 &models_dir,
584 Some(on_progress),
585 &opts,
586 ) => res.map(|_| ()).map_err(|e| e.to_string()),
587 _ = cancel.cancelled() => Err("cancelled".into()),
588 }
589 }
590}
591
592pub struct HfPullDriver;
594
595#[async_trait::async_trait]
596impl PullDriver for HfPullDriver {
597 async fn pull(
598 &self,
599 model: &str,
600 on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
601 cancel: CancellationToken,
602 ) -> Result<(), String> {
603 let on_progress: mold_core::download::DownloadProgressCallback =
604 std::sync::Arc::from(on_progress);
605 let opts = mold_core::download::PullOptions::default();
606 let model = model.to_string();
607 tokio::select! {
611 res = mold_core::download::pull_and_configure_with_callback(&model, on_progress, &opts) => {
612 res.map(|_| ()).map_err(|e| e.to_string())
613 }
614 _ = cancel.cancelled() => {
615 Err("cancelled".into())
619 }
620 }
621 }
622}
623
624fn cleanup_partials_for_model(model: &str) {
630 use mold_core::manifest::resolve_model_name;
631 let canonical = resolve_model_name(model);
632 let sanitized = canonical.replace(':', "-");
633 mold_core::download::remove_pulling_marker(&canonical);
635 #[cfg(test)]
636 test_hooks::record_cleanup(&canonical);
637 if let Ok(models_dir) = std::env::var("MOLD_MODELS_DIR") {
638 let target = std::path::PathBuf::from(models_dir).join(&sanitized);
639 cleanup_partials_in_dir(&target);
640 return;
641 }
642 if let Some(home) = dirs::home_dir() {
643 let target = home.join(".mold/models").join(&sanitized);
644 cleanup_partials_in_dir(&target);
645 }
646}
647
648#[cfg(test)]
652pub(crate) mod test_hooks {
653 use std::sync::Mutex;
654 static CLEANUPS: Mutex<Vec<String>> = Mutex::new(Vec::new());
655
656 pub(crate) fn record_cleanup(model: &str) {
657 if let Ok(mut v) = CLEANUPS.lock() {
658 v.push(model.to_string());
659 }
660 }
661
662 pub fn drain_cleanups() -> Vec<String> {
666 CLEANUPS
667 .lock()
668 .map(|mut v| std::mem::take(&mut *v))
669 .unwrap_or_default()
670 }
671}
672
673use mold_core::download::SHA256_VERIFIED_SUFFIX;
678
679pub(crate) fn cleanup_partials_in_dir(dir: &std::path::Path) {
693 let root = match dir.canonicalize() {
696 Ok(p) => p,
697 Err(_) => return,
698 };
699 cleanup_partials_in_dir_under_root(dir, &root);
700}
701
702fn cleanup_partials_in_dir_under_root(dir: &std::path::Path, root: &std::path::Path) {
703 if !dir.is_dir() {
704 return;
705 }
706 let entries = match std::fs::read_dir(dir) {
707 Ok(e) => e,
708 Err(_) => return,
709 };
710 for entry in entries.flatten() {
711 let path = entry.path();
712
713 let meta = match path.symlink_metadata() {
718 Ok(m) => m,
719 Err(_) => continue,
720 };
721 let file_type = meta.file_type();
722 if file_type.is_symlink() {
723 tracing::warn!(
724 path = %path.display(),
725 "cleanup_partials: skipping symlink (not following)",
726 );
727 continue;
728 }
729 if file_type.is_dir() {
730 cleanup_partials_in_dir_under_root(&path, root);
731 let _ = std::fs::remove_dir(&path);
733 continue;
734 }
735 if !file_type.is_file() {
736 continue;
737 }
738 let name = match path.file_name().and_then(|n| n.to_str()) {
739 Some(n) => n,
740 None => continue,
741 };
742 if name.ends_with(SHA256_VERIFIED_SUFFIX) {
744 continue;
745 }
746 let marker = path.with_file_name(format!("{name}{SHA256_VERIFIED_SUFFIX}"));
748 if marker.exists() {
749 continue;
750 }
751 match path.canonicalize() {
756 Ok(canon) => {
757 if !canon.starts_with(root) {
758 tracing::warn!(
759 path = %path.display(),
760 canon = %canon.display(),
761 root = %root.display(),
762 "cleanup_partials: refusing to delete file outside models dir",
763 );
764 continue;
765 }
766 }
767 Err(_) => continue,
768 }
769 let _ = std::fs::remove_file(&path);
770 }
771}
772
773pub fn spawn_driver(
776 queue: Arc<DownloadQueue>,
777 driver: Arc<dyn PullDriver>,
778 recipe_driver: Arc<dyn RecipePullDriver>,
779 models_dir: std::path::PathBuf,
780 shutdown: CancellationToken,
781) -> tokio::task::JoinHandle<()> {
782 tokio::spawn(async move {
783 tracing::info!("download queue driver started");
784 let mut running = tokio::task::JoinSet::new();
785 loop {
786 if shutdown.is_cancelled() {
787 queue.cancel_all_active().await;
788 while running.join_next().await.is_some() {}
789 break;
790 }
791 while running.len() < DOWNLOAD_CONCURRENCY {
792 let Some(job) = queue.take_next_queued() else {
793 break;
794 };
795 let queue = queue.clone();
796 let driver = driver.clone();
797 let recipe_driver = recipe_driver.clone();
798 let models_dir = models_dir.clone();
799 running.spawn(async move {
800 run_one_job(&queue, &driver, &recipe_driver, &models_dir, job).await;
801 });
802 }
803
804 if running.is_empty() {
805 queue.wait_for_work(&shutdown).await;
806 } else {
807 tokio::select! {
808 _ = queue.notify.notified() => {},
809 _ = running.join_next() => {},
810 _ = shutdown.cancelled() => {},
811 }
812 }
813 }
814 tracing::info!("download queue driver exiting");
815 })
816}
817
818async fn run_one_job(
819 queue: &Arc<DownloadQueue>,
820 driver: &Arc<dyn PullDriver>,
821 recipe_driver: &Arc<dyn RecipePullDriver>,
822 models_dir: &std::path::Path,
823 mut job: DownloadJob,
824) {
825 job.status = JobStatus::Active;
826 job.started_at = Some(now_ms());
827 let cancel = CancellationToken::new();
828 let handle_job = job.clone();
829
830 let recipe_payload = queue.take_recipe_payload(&job.id);
833
834 let active_handle = ActiveHandle {
837 job: handle_job,
838 abort: cancel.clone(),
839 };
840 queue.set_active(active_handle).await;
841
842 let _ = try_pull_with_retry(
843 queue,
844 driver,
845 recipe_driver,
846 models_dir,
847 recipe_payload.as_ref(),
848 &mut job,
849 cancel.clone(),
850 )
851 .await;
852
853 let final_job = queue
855 .with_active(&job.id, |a| a.clone())
856 .await
857 .unwrap_or_else(|| job.clone());
858 queue.clear_active(&job.id).await;
859 queue.push_history(final_job);
860}
861
862#[allow(clippy::too_many_arguments)]
863async fn try_pull_with_retry(
864 queue: &Arc<DownloadQueue>,
865 driver: &Arc<dyn PullDriver>,
866 recipe_driver: &Arc<dyn RecipePullDriver>,
867 models_dir: &std::path::Path,
868 recipe_payload: Option<&RecipePayload>,
869 job: &mut DownloadJob,
870 cancel: CancellationToken,
871) -> Result<(), ()> {
872 for attempt in 0..=1u8 {
874 let result = run_single_attempt(
875 queue,
876 driver,
877 recipe_driver,
878 models_dir,
879 recipe_payload,
880 job,
881 cancel.clone(),
882 )
883 .await;
884 match result {
885 Ok(()) => {
886 job.status = JobStatus::Completed;
887 job.completed_at = Some(now_ms());
888 queue
890 .with_active(&job.id, |a| {
891 *a = job.clone();
892 })
893 .await;
894 queue.emit(DownloadEvent::JobDone {
895 id: job.id.clone(),
896 model: job.model.clone(),
897 });
898 queue.settle_for_group(&job.id, JobStatus::Completed);
899 return Ok(());
900 }
901 Err(AttemptError::Cancelled) => {
902 job.status = JobStatus::Cancelled;
903 job.completed_at = Some(now_ms());
904 queue
905 .with_active(&job.id, |a| {
906 *a = job.clone();
907 })
908 .await;
909 cleanup_partials_for_model(&job.model);
910 queue.emit(DownloadEvent::JobCancelled { id: job.id.clone() });
911 queue.settle_for_group(&job.id, JobStatus::Cancelled);
912 return Err(());
913 }
914 Err(AttemptError::Failed(msg)) => {
915 if attempt == 0 {
916 tracing::warn!(model = %job.model, "pull attempt 1 failed: {msg} — retrying in 5s");
917 tokio::select! {
918 _ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {},
919 _ = cancel.cancelled() => {
920 job.status = JobStatus::Cancelled;
921 job.completed_at = Some(now_ms());
922 queue
923 .with_active(&job.id, |a| {
924 *a = job.clone();
925 })
926 .await;
927 cleanup_partials_for_model(&job.model);
928 queue.emit(DownloadEvent::JobCancelled { id: job.id.clone() });
929 queue.settle_for_group(&job.id, JobStatus::Cancelled);
930 return Err(());
931 }
932 }
933 continue;
934 }
935 job.status = JobStatus::Failed;
936 job.error = Some(msg.clone());
937 job.completed_at = Some(now_ms());
938 queue
939 .with_active(&job.id, |a| {
940 *a = job.clone();
941 })
942 .await;
943 cleanup_partials_for_model(&job.model);
944 queue.emit(DownloadEvent::JobFailed {
945 id: job.id.clone(),
946 error: msg,
947 });
948 queue.settle_for_group(&job.id, JobStatus::Failed);
949 return Err(());
950 }
951 }
952 }
953 Err(())
954}
955
956enum AttemptError {
957 Cancelled,
958 Failed(String),
959}
960
961#[allow(clippy::too_many_arguments)]
962async fn run_single_attempt(
963 queue: &Arc<DownloadQueue>,
964 driver: &Arc<dyn PullDriver>,
965 recipe_driver: &Arc<dyn RecipePullDriver>,
966 models_dir: &std::path::Path,
967 recipe_payload: Option<&RecipePayload>,
968 job: &mut DownloadJob,
969 cancel: CancellationToken,
970) -> Result<(), AttemptError> {
971 let (tx, mut rx) =
974 tokio::sync::mpsc::unbounded_channel::<mold_core::download::DownloadProgressEvent>();
975 let on_progress = Box::new(move |evt: mold_core::download::DownloadProgressEvent| {
976 let _ = tx.send(evt);
977 });
978
979 let queue_for_drain = queue.clone();
980 let job_id = job.id.clone();
981 let drain_handle = tokio::spawn(async move {
982 while let Some(evt) = rx.recv().await {
983 translate_event(&queue_for_drain, &job_id, evt).await;
984 }
985 });
986
987 let result = match recipe_payload {
988 Some(payload) => {
989 recipe_driver
990 .pull(
991 payload.clone(),
992 models_dir.to_path_buf(),
993 on_progress,
994 cancel.clone(),
995 )
996 .await
997 }
998 None => driver.pull(&job.model, on_progress, cancel.clone()).await,
999 };
1000 let _ = drain_handle.await;
1002
1003 match result {
1004 Ok(()) => Ok(()),
1005 Err(msg) if cancel.is_cancelled() => {
1006 let _ = msg;
1007 Err(AttemptError::Cancelled)
1008 }
1009 Err(msg) => Err(AttemptError::Failed(msg)),
1010 }
1011}
1012
1013async fn translate_event(
1014 queue: &Arc<DownloadQueue>,
1015 job_id: &str,
1016 evt: mold_core::download::DownloadProgressEvent,
1017) {
1018 use mold_core::download::DownloadProgressEvent as P;
1019 let queue = queue.clone();
1020 let id = job_id.to_string();
1021 {
1022 match evt {
1023 P::FileStart {
1024 total_files,
1025 batch_bytes_total,
1026 filename,
1027 file_index,
1028 ..
1029 } => {
1030 let emitted_started = queue
1031 .with_active(&id, |j| {
1032 if j.files_total == 0 {
1033 j.files_total = total_files;
1034 j.bytes_total = batch_bytes_total;
1035 true
1036 } else {
1037 false
1038 }
1039 })
1040 .await
1041 .unwrap_or(false);
1042 if emitted_started {
1043 queue.emit(DownloadEvent::Started {
1044 id: id.clone(),
1045 files_total: total_files,
1046 bytes_total: batch_bytes_total,
1047 });
1048 }
1049 let _ = file_index;
1050 queue
1051 .with_active(&id, |j| {
1052 j.current_file = Some(filename.clone());
1053 })
1054 .await;
1055 }
1056 P::FileProgress {
1057 filename,
1058 bytes_downloaded,
1059 batch_bytes_downloaded,
1060 batch_bytes_total,
1061 file_index,
1062 bytes_total,
1063 ..
1064 } => {
1065 let _ = (bytes_downloaded, bytes_total, file_index);
1066 let files_done = queue
1073 .with_active(&id, |j| {
1074 j.bytes_done = batch_bytes_downloaded;
1075 if j.bytes_total == 0 {
1076 j.bytes_total = batch_bytes_total;
1077 }
1078 j.current_file = Some(filename.clone());
1079 j.files_done
1080 })
1081 .await
1082 .unwrap_or(0);
1083 queue.emit(DownloadEvent::Progress {
1084 id: id.clone(),
1085 files_done,
1086 bytes_done: batch_bytes_downloaded,
1087 current_file: Some(filename),
1088 });
1089 }
1090 P::FileDone {
1091 filename,
1092 file_index,
1093 total_files,
1094 batch_bytes_downloaded,
1095 batch_bytes_total,
1096 ..
1097 } => {
1098 let _ = batch_bytes_total;
1099 queue
1100 .with_active(&id, |j| {
1101 j.files_done = file_index + 1;
1102 j.files_total = total_files;
1103 j.bytes_done = batch_bytes_downloaded;
1104 })
1105 .await;
1106 queue.emit(DownloadEvent::FileDone {
1107 id: id.clone(),
1108 filename,
1109 });
1110 }
1111 P::Status { message } => {
1112 queue
1115 .with_active(&id, |j| {
1116 j.current_file = Some(message.clone());
1117 })
1118 .await;
1119 }
1120 }
1121 }
1122}
1123
1124fn now_ms() -> i64 {
1125 use std::time::{SystemTime, UNIX_EPOCH};
1126 SystemTime::now()
1127 .duration_since(UNIX_EPOCH)
1128 .map(|d| d.as_millis() as i64)
1129 .unwrap_or(0)
1130}
1131
1132#[cfg(test)]
1133#[path = "downloads_test.rs"]
1134mod tests;