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 hf_fallback_tokens: StdMutex<HashMap<String, String>>,
96 groups: StdMutex<HashMap<String, CatalogGroup>>,
102}
103
104#[derive(Debug, thiserror::Error)]
105pub enum EnqueueError {
106 #[error("unknown model '{0}'. Run 'mold list' to see available models.")]
107 UnknownModel(String),
108 #[error("download queue lock poisoned")]
109 LockPoisoned,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum EnqueueOutcome {
114 AlreadyPresent,
116 Created,
118}
119
120impl DownloadQueue {
121 pub fn new() -> Arc<Self> {
122 let (events, _rx) = broadcast::channel(EVENT_BUFFER);
123 Arc::new(Self {
124 active: AsyncMutex::new(HashMap::new()),
125 queued: StdMutex::new(VecDeque::new()),
126 history: StdMutex::new(VecDeque::new()),
127 events,
128 notify: Notify::new(),
129 recipe_payloads: StdMutex::new(HashMap::new()),
130 hf_fallback_tokens: StdMutex::new(HashMap::new()),
131 groups: StdMutex::new(HashMap::new()),
132 })
133 }
134
135 #[cfg(test)]
138 pub fn new_for_test() -> Arc<Self> {
139 Self::new()
140 }
141
142 pub fn subscribe(&self) -> broadcast::Receiver<DownloadEvent> {
143 self.events.subscribe()
144 }
145
146 pub async fn listing(&self) -> DownloadsListing {
148 let mut active_jobs: Vec<DownloadJob> = self
149 .active
150 .lock()
151 .await
152 .values()
153 .map(|handle| handle.job.clone())
154 .collect();
155 active_jobs.sort_by_key(|job| (job.started_at, job.id.clone()));
156 let active = active_jobs.first().cloned();
157 let queued: Vec<DownloadJob> = self
158 .queued
159 .lock()
160 .expect("queued lock poisoned")
161 .iter()
162 .cloned()
163 .collect();
164 let history: Vec<DownloadJob> = self
165 .history
166 .lock()
167 .expect("history lock poisoned")
168 .iter()
169 .cloned()
170 .collect();
171 DownloadsListing {
172 active_jobs,
173 active,
174 queued,
175 history,
176 }
177 }
178
179 pub async fn enqueue(
183 &self,
184 model: String,
185 ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
186 self.enqueue_with_hf_fallback(model, None).await
187 }
188
189 pub async fn enqueue_with_hf_fallback(
190 &self,
191 model: String,
192 hf_fallback_token: Option<String>,
193 ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
194 let canonical = mold_core::manifest::resolve_model_name(&model);
197 if mold_core::manifest::find_manifest(&canonical).is_none() {
198 return Err(EnqueueError::UnknownModel(model));
199 }
200
201 {
203 let active = self.active.lock().await;
204 if let Some(handle) = active.values().find(|handle| handle.job.model == canonical) {
205 self.store_hf_fallback_token(&handle.job.id, hf_fallback_token.as_deref())?;
206 return Ok((handle.job.id.clone(), 0, EnqueueOutcome::AlreadyPresent));
207 }
208 }
209 {
210 let queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
211 if let Some((idx, existing)) = queued
212 .iter()
213 .enumerate()
214 .find(|(_, j)| j.model == canonical)
215 {
216 self.store_hf_fallback_token(&existing.id, hf_fallback_token.as_deref())?;
217 return Ok((existing.id.clone(), idx + 1, EnqueueOutcome::AlreadyPresent));
218 }
219 }
220
221 let id = uuid::Uuid::new_v4().to_string();
222 let job = DownloadJob {
223 id: id.clone(),
224 model: canonical.clone(),
225 catalog_id: None,
226 status: JobStatus::Queued,
227 files_done: 0,
228 files_total: 0,
229 bytes_done: 0,
230 bytes_total: 0,
231 current_file: None,
232 started_at: None,
233 completed_at: None,
234 error: None,
235 };
236
237 let position = {
238 let mut queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
239 queued.push_back(job);
240 queued.len() };
242 self.store_hf_fallback_token(&id, hf_fallback_token.as_deref())?;
243 let _ = self.events.send(DownloadEvent::Enqueued {
244 id: id.clone(),
245 model: canonical,
246 position,
247 });
248 self.notify.notify_one();
249 Ok((id, position, EnqueueOutcome::Created))
250 }
251
252 pub async fn enqueue_recipe(
262 &self,
263 payload: RecipePayload,
264 ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
265 if payload.catalog_id.trim().is_empty() {
266 return Err(EnqueueError::UnknownModel(payload.catalog_id));
267 }
268
269 {
271 let active = self.active.lock().await;
272 if let Some(handle) = active
273 .values()
274 .find(|handle| handle.job.model == payload.catalog_id)
275 {
276 return Ok((handle.job.id.clone(), 0, EnqueueOutcome::AlreadyPresent));
277 }
278 }
279 {
280 let queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
281 if let Some((idx, existing)) = queued
282 .iter()
283 .enumerate()
284 .find(|(_, j)| j.model == payload.catalog_id)
285 {
286 return Ok((existing.id.clone(), idx + 1, EnqueueOutcome::AlreadyPresent));
287 }
288 }
289
290 let id = uuid::Uuid::new_v4().to_string();
291 let job = DownloadJob {
292 id: id.clone(),
293 model: payload.catalog_id.clone(),
294 catalog_id: Some(payload.catalog_id.clone()),
295 status: JobStatus::Queued,
296 files_done: 0,
297 files_total: payload.files.len(),
298 bytes_done: 0,
299 bytes_total: payload.files.iter().filter_map(|f| f.size_bytes).sum(),
300 current_file: None,
301 started_at: None,
302 completed_at: None,
303 error: None,
304 };
305
306 {
309 let mut payloads = self
310 .recipe_payloads
311 .lock()
312 .map_err(|_| EnqueueError::LockPoisoned)?;
313 payloads.insert(id.clone(), payload.clone());
314 }
315
316 let position = {
317 let mut queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
318 queued.push_back(job);
319 queued.len()
320 };
321 let _ = self.events.send(DownloadEvent::Enqueued {
322 id: id.clone(),
323 model: payload.catalog_id,
324 position,
325 });
326 self.notify.notify_one();
327 Ok((id, position, EnqueueOutcome::Created))
328 }
329
330 pub async fn enqueue_in_group(
336 &self,
337 model: String,
338 catalog_id: &str,
339 ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
340 let (id, pos, outcome) = self.enqueue(model).await?;
341 self.register_in_group(catalog_id, &id);
342 Ok((id, pos, outcome))
343 }
344
345 pub async fn enqueue_in_group_with_hf_fallback(
346 &self,
347 model: String,
348 catalog_id: &str,
349 hf_fallback_token: Option<String>,
350 ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
351 let (id, pos, outcome) = self
352 .enqueue_with_hf_fallback(model, hf_fallback_token)
353 .await?;
354 self.register_in_group(catalog_id, &id);
355 Ok((id, pos, outcome))
356 }
357
358 pub async fn enqueue_recipe_in_group(
361 &self,
362 payload: RecipePayload,
363 catalog_id: &str,
364 ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
365 let (id, pos, outcome) = self.enqueue_recipe(payload).await?;
366 self.register_in_group(catalog_id, &id);
367 Ok((id, pos, outcome))
368 }
369
370 fn register_in_group(&self, catalog_id: &str, job_id: &str) {
371 self.tag_job_with_catalog_id(catalog_id, job_id);
372 let mut groups = match self.groups.lock() {
373 Ok(g) => g,
374 Err(_) => return, };
376 let entry = groups
377 .entry(catalog_id.to_string())
378 .or_insert_with(|| CatalogGroup {
379 catalog_id: catalog_id.to_string(),
380 job_ids: std::collections::HashSet::new(),
381 outcomes: HashMap::new(),
382 });
383 entry.job_ids.insert(job_id.to_string());
384 }
385
386 fn tag_job_with_catalog_id(&self, catalog_id: &str, job_id: &str) {
387 if let Ok(mut queued) = self.queued.lock() {
388 if let Some(job) = queued.iter_mut().find(|job| job.id == job_id) {
389 job.catalog_id = Some(catalog_id.to_string());
390 return;
391 }
392 }
393 if let Ok(mut history) = self.history.lock() {
394 if let Some(job) = history.iter_mut().find(|job| job.id == job_id) {
395 job.catalog_id = Some(catalog_id.to_string());
396 return;
397 }
398 }
399 if let Ok(mut active) = self.active.try_lock() {
400 if let Some(handle) = active.get_mut(job_id) {
401 handle.job.catalog_id = Some(catalog_id.to_string());
402 }
403 }
404 }
405
406 pub(crate) fn settle_for_group(&self, job_id: &str, status: JobStatus) {
415 let to_emit = {
417 let mut groups = match self.groups.lock() {
418 Ok(g) => g,
419 Err(_) => return,
420 };
421 let mut emit_for: Option<(String, bool)> = None;
422 for group in groups.values_mut() {
427 if !group.job_ids.contains(job_id) {
428 continue;
429 }
430 group.outcomes.insert(job_id.to_string(), status);
431 if group.outcomes.len() == group.job_ids.len() {
432 let ok = group
433 .outcomes
434 .values()
435 .all(|s| matches!(s, JobStatus::Completed));
436 emit_for = Some((group.catalog_id.clone(), ok));
437 }
438 break; }
440 if let Some((id, _)) = &emit_for {
441 groups.remove(id);
442 }
443 emit_for
444 };
445 if let Some((id, ok)) = to_emit {
446 self.emit(DownloadEvent::CatalogReady { id, ok });
447 }
448 }
449
450 pub(crate) fn take_recipe_payload(&self, job_id: &str) -> Option<RecipePayload> {
454 self.recipe_payloads
455 .lock()
456 .ok()
457 .and_then(|mut p| p.remove(job_id))
458 }
459
460 fn store_hf_fallback_token(
461 &self,
462 job_id: &str,
463 token: Option<&str>,
464 ) -> Result<(), EnqueueError> {
465 let Some(token) = token.map(str::trim).filter(|token| !token.is_empty()) else {
466 return Ok(());
467 };
468 self.hf_fallback_tokens
469 .lock()
470 .map_err(|_| EnqueueError::LockPoisoned)?
471 .insert(job_id.to_string(), token.to_string());
472 Ok(())
473 }
474
475 pub(crate) fn hf_fallback_token(&self, job_id: &str) -> Option<String> {
476 self.hf_fallback_tokens
477 .lock()
478 .ok()
479 .and_then(|tokens| tokens.get(job_id).cloned())
480 }
481
482 fn forget_hf_fallback_token(&self, job_id: &str) {
483 if let Ok(mut tokens) = self.hf_fallback_tokens.lock() {
484 tokens.remove(job_id);
485 }
486 }
487
488 pub async fn cancel(&self, id: &str) -> bool {
491 {
493 let mut queued = self.queued.lock().expect("queued lock poisoned");
494 if let Some(pos) = queued.iter().position(|j| j.id == id) {
495 let mut job = queued.remove(pos).expect("queued position must exist");
496 drop(queued);
497 job.status = JobStatus::Cancelled;
498 job.completed_at = Some(now_ms());
499 self.recipe_payloads
500 .lock()
501 .expect("recipe payload lock poisoned")
502 .remove(id);
503 self.hf_fallback_tokens
504 .lock()
505 .expect("HF fallback token lock poisoned")
506 .remove(id);
507 self.push_history(job);
508 let _ = self
509 .events
510 .send(DownloadEvent::JobCancelled { id: id.to_string() });
511 let _ = self
512 .events
513 .send(DownloadEvent::Dequeued { id: id.to_string() });
514 self.settle_for_group(id, JobStatus::Cancelled);
515 return true;
516 }
517 }
518 let active = self.active.lock().await;
520 if let Some(handle) = active.get(id) {
521 handle.abort.cancel();
522 return true;
524 }
525 false
527 }
528
529 pub(crate) fn push_history(&self, job: DownloadJob) {
532 let mut history = self.history.lock().expect("history lock poisoned");
533 if history.len() >= HISTORY_CAP {
534 history.pop_front();
535 }
536 history.push_back(job);
537 }
538
539 pub(crate) async fn wait_for_work(&self, shutdown: &CancellationToken) {
541 tokio::select! {
542 _ = self.notify.notified() => {},
543 _ = shutdown.cancelled() => {},
544 }
545 }
546
547 pub(crate) fn take_next_queued(&self) -> Option<DownloadJob> {
548 self.queued
549 .lock()
550 .expect("queued lock poisoned")
551 .pop_front()
552 }
553
554 pub(crate) async fn set_active(&self, handle: ActiveHandle) {
555 self.active
556 .lock()
557 .await
558 .insert(handle.job.id.clone(), handle);
559 }
560
561 pub(crate) async fn clear_active(&self, id: &str) -> Option<ActiveHandle> {
562 self.active.lock().await.remove(id)
563 }
564
565 pub(crate) async fn with_active<F, R>(&self, id: &str, f: F) -> Option<R>
566 where
567 F: FnOnce(&mut DownloadJob) -> R,
568 {
569 let mut active = self.active.lock().await;
570 active.get_mut(id).map(|a| f(&mut a.job))
571 }
572
573 async fn cancel_all_active(&self) {
574 for handle in self.active.lock().await.values() {
575 handle.abort.cancel();
576 }
577 }
578
579 pub(crate) fn emit(&self, event: DownloadEvent) {
580 let _ = self.events.send(event);
581 }
582}
583
584pub type HfFallbackTokenProvider = Arc<dyn Fn() -> Option<String> + Send + Sync>;
590
591#[async_trait::async_trait]
596pub trait PullDriver: Send + Sync + 'static {
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
604 async fn pull_with_token_provider(
608 &self,
609 model: &str,
610 hf_fallback_token: HfFallbackTokenProvider,
611 on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
612 cancel: CancellationToken,
613 ) -> Result<(), String> {
614 let _ = hf_fallback_token;
615 self.pull(model, on_progress, cancel).await
616 }
617}
618
619#[async_trait::async_trait]
624pub trait RecipePullDriver: Send + Sync + 'static {
625 async fn pull(
626 &self,
627 payload: RecipePayload,
628 models_dir: std::path::PathBuf,
629 on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
630 cancel: CancellationToken,
631 ) -> Result<(), String>;
632}
633
634pub struct CivitaiRecipeDriver;
636
637#[async_trait::async_trait]
638impl RecipePullDriver for CivitaiRecipeDriver {
639 async fn pull(
640 &self,
641 payload: RecipePayload,
642 models_dir: std::path::PathBuf,
643 on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
644 cancel: CancellationToken,
645 ) -> Result<(), String> {
646 let on_progress: mold_core::download::DownloadProgressCallback =
647 std::sync::Arc::from(on_progress);
648 let opts = mold_core::download::PullOptions::default();
649 let files: Vec<mold_core::download::RecipeFetchFile<'_>> = payload
650 .files
651 .iter()
652 .map(|f| mold_core::download::RecipeFetchFile {
653 url: f.url.as_str(),
654 dest: f.dest.as_str(),
655 sha256: f.sha256.as_deref(),
656 size_bytes: f.size_bytes,
657 })
658 .collect();
659 tokio::select! {
660 res = mold_core::download::fetch_recipe(
661 &payload.catalog_id,
662 &files,
663 payload.auth.clone(),
664 &models_dir,
665 Some(on_progress),
666 &opts,
667 ) => res.map(|_| ()).map_err(|e| e.to_string()),
668 _ = cancel.cancelled() => Err("cancelled".into()),
669 }
670 }
671}
672
673pub struct HfPullDriver;
675
676#[async_trait::async_trait]
677impl PullDriver for HfPullDriver {
678 async fn pull(
679 &self,
680 model: &str,
681 on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
682 cancel: CancellationToken,
683 ) -> Result<(), String> {
684 self.pull_with_token_provider(model, Arc::new(|| None), on_progress, cancel)
685 .await
686 }
687
688 async fn pull_with_token_provider(
689 &self,
690 model: &str,
691 hf_fallback_token: HfFallbackTokenProvider,
692 on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
693 cancel: CancellationToken,
694 ) -> Result<(), String> {
695 let on_progress: mold_core::download::DownloadProgressCallback =
696 std::sync::Arc::from(on_progress);
697 let model = model.to_string();
698 let pull = async {
699 let primary = mold_core::download::pull_and_configure_with_callback(
700 &model,
701 on_progress.clone(),
702 &Default::default(),
703 )
704 .await;
705 match primary {
706 Err(
707 mold_core::download::DownloadError::Unauthorized { .. }
708 | mold_core::download::DownloadError::GatedModel { .. },
709 ) => {
710 if let Some(token) = hf_fallback_token() {
711 mold_core::download::pull_and_configure_with_callback_and_hf_token(
712 &model,
713 on_progress,
714 &Default::default(),
715 Some(&token),
716 )
717 .await
718 } else {
719 primary
720 }
721 }
722 result => result,
723 }
724 };
725 tokio::select! {
729 res = pull => {
730 res.map(|_| ()).map_err(|e| e.to_string())
731 }
732 _ = cancel.cancelled() => {
733 Err("cancelled".into())
737 }
738 }
739 }
740}
741
742fn cleanup_partials_for_model(model: &str) {
748 use mold_core::manifest::resolve_model_name;
749 let canonical = resolve_model_name(model);
750 let sanitized = canonical.replace(':', "-");
751 mold_core::download::remove_pulling_marker(&canonical);
753 #[cfg(test)]
754 test_hooks::record_cleanup(&canonical);
755 if let Ok(models_dir) = std::env::var("MOLD_MODELS_DIR") {
756 let target = std::path::PathBuf::from(models_dir).join(&sanitized);
757 cleanup_partials_in_dir(&target);
758 return;
759 }
760 if let Some(home) = dirs::home_dir() {
761 let target = home.join(".mold/models").join(&sanitized);
762 cleanup_partials_in_dir(&target);
763 }
764}
765
766#[cfg(test)]
770pub(crate) mod test_hooks {
771 use std::sync::Mutex;
772 static CLEANUPS: Mutex<Vec<String>> = Mutex::new(Vec::new());
773
774 pub(crate) fn record_cleanup(model: &str) {
775 if let Ok(mut v) = CLEANUPS.lock() {
776 v.push(model.to_string());
777 }
778 }
779
780 pub fn drain_cleanups() -> Vec<String> {
784 CLEANUPS
785 .lock()
786 .map(|mut v| std::mem::take(&mut *v))
787 .unwrap_or_default()
788 }
789}
790
791use mold_core::download::SHA256_VERIFIED_SUFFIX;
796
797pub(crate) fn cleanup_partials_in_dir(dir: &std::path::Path) {
811 let root = match dir.canonicalize() {
814 Ok(p) => p,
815 Err(_) => return,
816 };
817 cleanup_partials_in_dir_under_root(dir, &root);
818}
819
820fn cleanup_partials_in_dir_under_root(dir: &std::path::Path, root: &std::path::Path) {
821 if !dir.is_dir() {
822 return;
823 }
824 let entries = match std::fs::read_dir(dir) {
825 Ok(e) => e,
826 Err(_) => return,
827 };
828 for entry in entries.flatten() {
829 let path = entry.path();
830
831 let meta = match path.symlink_metadata() {
836 Ok(m) => m,
837 Err(_) => continue,
838 };
839 let file_type = meta.file_type();
840 if file_type.is_symlink() {
841 tracing::warn!(
842 path = %path.display(),
843 "cleanup_partials: skipping symlink (not following)",
844 );
845 continue;
846 }
847 if file_type.is_dir() {
848 cleanup_partials_in_dir_under_root(&path, root);
849 let _ = std::fs::remove_dir(&path);
851 continue;
852 }
853 if !file_type.is_file() {
854 continue;
855 }
856 let name = match path.file_name().and_then(|n| n.to_str()) {
857 Some(n) => n,
858 None => continue,
859 };
860 if name.ends_with(SHA256_VERIFIED_SUFFIX) {
862 continue;
863 }
864 let marker = path.with_file_name(format!("{name}{SHA256_VERIFIED_SUFFIX}"));
866 if marker.exists() {
867 continue;
868 }
869 match path.canonicalize() {
874 Ok(canon) => {
875 if !canon.starts_with(root) {
876 tracing::warn!(
877 path = %path.display(),
878 canon = %canon.display(),
879 root = %root.display(),
880 "cleanup_partials: refusing to delete file outside models dir",
881 );
882 continue;
883 }
884 }
885 Err(_) => continue,
886 }
887 let _ = std::fs::remove_file(&path);
888 }
889}
890
891pub fn spawn_driver(
894 queue: Arc<DownloadQueue>,
895 driver: Arc<dyn PullDriver>,
896 recipe_driver: Arc<dyn RecipePullDriver>,
897 models_dir: std::path::PathBuf,
898 shutdown: CancellationToken,
899) -> tokio::task::JoinHandle<()> {
900 tokio::spawn(async move {
901 tracing::info!("download queue driver started");
902 let mut running = tokio::task::JoinSet::new();
903 loop {
904 if shutdown.is_cancelled() {
905 queue.cancel_all_active().await;
906 while running.join_next().await.is_some() {}
907 break;
908 }
909 while running.len() < DOWNLOAD_CONCURRENCY {
910 let Some(job) = queue.take_next_queued() else {
911 break;
912 };
913 let queue = queue.clone();
914 let driver = driver.clone();
915 let recipe_driver = recipe_driver.clone();
916 let models_dir = models_dir.clone();
917 running.spawn(async move {
918 run_one_job(&queue, &driver, &recipe_driver, &models_dir, job).await;
919 });
920 }
921
922 if running.is_empty() {
923 queue.wait_for_work(&shutdown).await;
924 } else {
925 tokio::select! {
926 _ = queue.notify.notified() => {},
927 _ = running.join_next() => {},
928 _ = shutdown.cancelled() => {},
929 }
930 }
931 }
932 tracing::info!("download queue driver exiting");
933 })
934}
935
936async fn run_one_job(
937 queue: &Arc<DownloadQueue>,
938 driver: &Arc<dyn PullDriver>,
939 recipe_driver: &Arc<dyn RecipePullDriver>,
940 models_dir: &std::path::Path,
941 mut job: DownloadJob,
942) {
943 job.status = JobStatus::Active;
944 job.started_at = Some(now_ms());
945 let cancel = CancellationToken::new();
946 let handle_job = job.clone();
947
948 let recipe_payload = queue.take_recipe_payload(&job.id);
951 let queue_for_token = queue.clone();
952 let token_job_id = job.id.clone();
953 let hf_fallback_token: HfFallbackTokenProvider =
954 Arc::new(move || queue_for_token.hf_fallback_token(&token_job_id));
955
956 let active_handle = ActiveHandle {
959 job: handle_job,
960 abort: cancel.clone(),
961 };
962 queue.set_active(active_handle).await;
963
964 let _ = try_pull_with_retry(
965 queue,
966 driver,
967 recipe_driver,
968 models_dir,
969 recipe_payload.as_ref(),
970 hf_fallback_token,
971 &mut job,
972 cancel.clone(),
973 )
974 .await;
975 queue.forget_hf_fallback_token(&job.id);
976
977 let final_job = queue
979 .with_active(&job.id, |a| a.clone())
980 .await
981 .unwrap_or_else(|| job.clone());
982 queue.clear_active(&job.id).await;
983 queue.push_history(final_job);
984}
985
986#[allow(clippy::too_many_arguments)]
987async fn try_pull_with_retry(
988 queue: &Arc<DownloadQueue>,
989 driver: &Arc<dyn PullDriver>,
990 recipe_driver: &Arc<dyn RecipePullDriver>,
991 models_dir: &std::path::Path,
992 recipe_payload: Option<&RecipePayload>,
993 hf_fallback_token: HfFallbackTokenProvider,
994 job: &mut DownloadJob,
995 cancel: CancellationToken,
996) -> Result<(), ()> {
997 for attempt in 0..=1u8 {
999 let result = run_single_attempt(
1000 queue,
1001 driver,
1002 recipe_driver,
1003 models_dir,
1004 recipe_payload,
1005 hf_fallback_token.clone(),
1006 job,
1007 cancel.clone(),
1008 )
1009 .await;
1010 match result {
1011 Ok(()) => {
1012 job.status = JobStatus::Completed;
1013 job.completed_at = Some(now_ms());
1014 queue
1016 .with_active(&job.id, |a| {
1017 *a = job.clone();
1018 })
1019 .await;
1020 queue.emit(DownloadEvent::JobDone {
1021 id: job.id.clone(),
1022 model: job.model.clone(),
1023 });
1024 queue.settle_for_group(&job.id, JobStatus::Completed);
1025 return Ok(());
1026 }
1027 Err(AttemptError::Cancelled) => {
1028 job.status = JobStatus::Cancelled;
1029 job.completed_at = Some(now_ms());
1030 queue
1031 .with_active(&job.id, |a| {
1032 *a = job.clone();
1033 })
1034 .await;
1035 cleanup_partials_for_model(&job.model);
1036 queue.emit(DownloadEvent::JobCancelled { id: job.id.clone() });
1037 queue.settle_for_group(&job.id, JobStatus::Cancelled);
1038 return Err(());
1039 }
1040 Err(AttemptError::Failed(msg)) => {
1041 if attempt == 0 {
1042 tracing::warn!(model = %job.model, "pull attempt 1 failed: {msg} — retrying in 5s");
1043 tokio::select! {
1044 _ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {},
1045 _ = cancel.cancelled() => {
1046 job.status = JobStatus::Cancelled;
1047 job.completed_at = Some(now_ms());
1048 queue
1049 .with_active(&job.id, |a| {
1050 *a = job.clone();
1051 })
1052 .await;
1053 cleanup_partials_for_model(&job.model);
1054 queue.emit(DownloadEvent::JobCancelled { id: job.id.clone() });
1055 queue.settle_for_group(&job.id, JobStatus::Cancelled);
1056 return Err(());
1057 }
1058 }
1059 continue;
1060 }
1061 job.status = JobStatus::Failed;
1062 job.error = Some(msg.clone());
1063 job.completed_at = Some(now_ms());
1064 queue
1065 .with_active(&job.id, |a| {
1066 *a = job.clone();
1067 })
1068 .await;
1069 cleanup_partials_for_model(&job.model);
1070 queue.emit(DownloadEvent::JobFailed {
1071 id: job.id.clone(),
1072 error: msg,
1073 });
1074 queue.settle_for_group(&job.id, JobStatus::Failed);
1075 return Err(());
1076 }
1077 }
1078 }
1079 Err(())
1080}
1081
1082enum AttemptError {
1083 Cancelled,
1084 Failed(String),
1085}
1086
1087#[allow(clippy::too_many_arguments)]
1088async fn run_single_attempt(
1089 queue: &Arc<DownloadQueue>,
1090 driver: &Arc<dyn PullDriver>,
1091 recipe_driver: &Arc<dyn RecipePullDriver>,
1092 models_dir: &std::path::Path,
1093 recipe_payload: Option<&RecipePayload>,
1094 hf_fallback_token: HfFallbackTokenProvider,
1095 job: &mut DownloadJob,
1096 cancel: CancellationToken,
1097) -> Result<(), AttemptError> {
1098 let (tx, mut rx) =
1101 tokio::sync::mpsc::unbounded_channel::<mold_core::download::DownloadProgressEvent>();
1102 let on_progress = Box::new(move |evt: mold_core::download::DownloadProgressEvent| {
1103 let _ = tx.send(evt);
1104 });
1105
1106 let queue_for_drain = queue.clone();
1107 let job_id = job.id.clone();
1108 let drain_handle = tokio::spawn(async move {
1109 while let Some(evt) = rx.recv().await {
1110 translate_event(&queue_for_drain, &job_id, evt).await;
1111 }
1112 });
1113
1114 let result = match recipe_payload {
1115 Some(payload) => {
1116 recipe_driver
1117 .pull(
1118 payload.clone(),
1119 models_dir.to_path_buf(),
1120 on_progress,
1121 cancel.clone(),
1122 )
1123 .await
1124 }
1125 None => {
1126 driver
1127 .pull_with_token_provider(
1128 &job.model,
1129 hf_fallback_token,
1130 on_progress,
1131 cancel.clone(),
1132 )
1133 .await
1134 }
1135 };
1136 let _ = drain_handle.await;
1138
1139 match result {
1140 Ok(()) => Ok(()),
1141 Err(msg) if cancel.is_cancelled() => {
1142 let _ = msg;
1143 Err(AttemptError::Cancelled)
1144 }
1145 Err(msg) => Err(AttemptError::Failed(msg)),
1146 }
1147}
1148
1149async fn translate_event(
1150 queue: &Arc<DownloadQueue>,
1151 job_id: &str,
1152 evt: mold_core::download::DownloadProgressEvent,
1153) {
1154 use mold_core::download::DownloadProgressEvent as P;
1155 let queue = queue.clone();
1156 let id = job_id.to_string();
1157 {
1158 match evt {
1159 P::FileStart {
1160 total_files,
1161 batch_bytes_total,
1162 filename,
1163 file_index,
1164 ..
1165 } => {
1166 let emitted_started = queue
1167 .with_active(&id, |j| {
1168 if j.files_total == 0 {
1169 j.files_total = total_files;
1170 j.bytes_total = batch_bytes_total;
1171 true
1172 } else {
1173 false
1174 }
1175 })
1176 .await
1177 .unwrap_or(false);
1178 if emitted_started {
1179 queue.emit(DownloadEvent::Started {
1180 id: id.clone(),
1181 files_total: total_files,
1182 bytes_total: batch_bytes_total,
1183 });
1184 }
1185 let _ = file_index;
1186 queue
1187 .with_active(&id, |j| {
1188 j.current_file = Some(filename.clone());
1189 })
1190 .await;
1191 }
1192 P::FileProgress {
1193 filename,
1194 bytes_downloaded,
1195 batch_bytes_downloaded,
1196 batch_bytes_total,
1197 file_index,
1198 bytes_total,
1199 ..
1200 } => {
1201 let _ = (bytes_downloaded, bytes_total, file_index);
1202 let files_done = queue
1209 .with_active(&id, |j| {
1210 j.bytes_done = batch_bytes_downloaded;
1211 if j.bytes_total == 0 {
1212 j.bytes_total = batch_bytes_total;
1213 }
1214 j.current_file = Some(filename.clone());
1215 j.files_done
1216 })
1217 .await
1218 .unwrap_or(0);
1219 queue.emit(DownloadEvent::Progress {
1220 id: id.clone(),
1221 files_done,
1222 bytes_done: batch_bytes_downloaded,
1223 current_file: Some(filename),
1224 });
1225 }
1226 P::FileDone {
1227 filename,
1228 file_index,
1229 total_files,
1230 batch_bytes_downloaded,
1231 batch_bytes_total,
1232 ..
1233 } => {
1234 let _ = batch_bytes_total;
1235 queue
1236 .with_active(&id, |j| {
1237 j.files_done = file_index + 1;
1238 j.files_total = total_files;
1239 j.bytes_done = batch_bytes_downloaded;
1240 })
1241 .await;
1242 queue.emit(DownloadEvent::FileDone {
1243 id: id.clone(),
1244 filename,
1245 });
1246 }
1247 P::Status { message } => {
1248 queue
1251 .with_active(&id, |j| {
1252 j.current_file = Some(message.clone());
1253 })
1254 .await;
1255 }
1256 }
1257 }
1258}
1259
1260fn now_ms() -> i64 {
1261 use std::time::{SystemTime, UNIX_EPOCH};
1262 SystemTime::now()
1263 .duration_since(UNIX_EPOCH)
1264 .map(|d| d.as_millis() as i64)
1265 .unwrap_or(0)
1266}
1267
1268#[cfg(test)]
1269#[path = "downloads_test.rs"]
1270mod tests;