Skip to main content

mold_server/
downloads.rs

1//! Bounded-parallel download queue wrapping `mold_core::download::pull_model_with_callback`.
2//!
3//! One long-running driver task pulls jobs off a `VecDeque`, spawns a pull
4//! task per job, forwards `DownloadProgressEvent` → `DownloadEvent` on a
5//! broadcast channel, and cleans up on completion or cancellation.
6//!
7//! **Agent A boundary** — this module is owned by the Downloads phase. Do
8//! not take a lock on resources (Agent B) or placement (Agent C) from here.
9
10use 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/// Owned counterpart of [`mold_core::download::RecipeFetchFile`]. The queue
19/// stores files until the driver task picks the job up, so we can't carry
20/// borrowed lifetimes here.
21#[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/// Everything the recipe driver needs to fetch a `cv:<id>` primary
30/// safetensors. Stored on the queue separately from `DownloadJob` so the
31/// public `DownloadJob` type (broadcast / `/api/downloads` listing) stays
32/// unchanged; only the driver dispatch reads it.
33#[derive(Clone, Debug)]
34pub struct RecipePayload {
35    /// Catalog id (e.g. `cv:618692`); used as both the dedup key and the
36    /// per-recipe subdir name (sanitized via `replace(':', "-")`).
37    pub catalog_id: String,
38    pub files: Vec<OwnedRecipeFile>,
39    pub auth: RecipeAuth,
40}
41
42/// Capacity of the broadcast channel. Slow subscribers see the oldest events
43/// lag (we accept this — the SPA will also re-fetch `/api/downloads` on reconnect).
44pub const EVENT_BUFFER: usize = 256;
45
46/// Max history entries retained for the drawer's "Recent" section.
47pub const HISTORY_CAP: usize = 20;
48
49/// Two simultaneous model pulls make good use of fast remote hosts without
50/// multiplying disk pressure for large checkpoints without bound.
51pub const DOWNLOAD_CONCURRENCY: usize = 2;
52
53/// Active download handle.
54pub struct ActiveHandle {
55    pub job: DownloadJob,
56    pub abort: CancellationToken,
57}
58
59/// Membership ledger for a catalog entry's downloads. Tracks every job
60/// (primary + companions) that belongs to a single user-visible catalog
61/// id (`cv:1759168`, an HF entry, etc.) so the queue knows when the *whole*
62/// entry is ready — not just when one of its jobs finished.
63///
64/// Why this exists: `catalog_api::post_catalog_download` enqueues companions
65/// (CLIP-L/G, VAE) before the primary checkpoint. Pre-C, the SPA refreshed
66/// its model list on the primary's `JobDone` event, which sometimes fired
67/// before companions were on disk — that's the "model sometimes doesn't
68/// show up after download" bug. Now the SPA listens for `CatalogReady`,
69/// emitted exactly once when the last job in the group settles.
70#[derive(Debug, Clone)]
71struct CatalogGroup {
72    catalog_id: String,
73    /// Every job that must settle before the group is ready.
74    job_ids: std::collections::HashSet<String>,
75    /// Final status keyed by job id. Group is ready when
76    /// `outcomes.len() == job_ids.len()`.
77    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    /// Wakes the driver task when new work arrives.
86    notify: Notify,
87    /// Recipe payloads indexed by job id. Populated by `enqueue_recipe`,
88    /// drained by the driver task before running the job. Manifest jobs
89    /// have no entry here and fall through to the `PullDriver` path.
90    recipe_payloads: StdMutex<HashMap<String, RecipePayload>>,
91    /// Request-scoped HF fallback tokens for manifest jobs initiated by a
92    /// trusted remote desktop client. Tokens live only until the job settles
93    /// (or is cancelled while queued) and never appear in the public download
94    /// listing or event stream.
95    hf_fallback_tokens: StdMutex<HashMap<String, String>>,
96    /// Catalog group bookkeeping. Keyed by catalog id; cleared when the
97    /// group emits `CatalogReady`. Memory-only — server restart resets
98    /// the ledger, which is fine because in-flight downloads were lost
99    /// too and the next `manifest_files_exist` poll will report any
100    /// half-done install as incomplete.
101    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    /// Already in the queue or currently active — no new job created.
115    AlreadyPresent,
116    /// Created a new job at the given 0-based position (0 = will start next).
117    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    /// Testing helper — same as `new()` but returns a non-`Arc` for
136    /// direct `await` use. Callers that need the driver running must wrap in Arc.
137    #[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    /// Returns the current active/queued/history snapshot.
147    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    /// Enqueue a model for download. Returns `(job_id, position, outcome)`.
180    /// Position 0 means the job will run immediately after the driver wakes;
181    /// N means N jobs are ahead. Duplicate enqueue returns the existing id.
182    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        // Manifest validation up front so the caller gets a real 400 instead of a
195        // background failure.
196        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        // Check for active/queued duplicate.
202        {
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() // position shown to the user is 1-based in the drawer
241        };
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    /// Enqueue a recipe-driven download (Civitai single-file checkpoint).
253    /// Returns `(job_id, position, outcome)`. Dedupes on `payload.catalog_id`
254    /// — re-enqueuing the same recipe returns the existing job id so the
255    /// SPA's idempotent retry doesn't double-pull.
256    ///
257    /// Recipe jobs use the catalog id (e.g. `cv:618692`) as `DownloadJob.model`
258    /// so the drawer / API listing show something meaningful. The driver task
259    /// looks up the recipe payload by job id and dispatches to the recipe
260    /// driver instead of the manifest driver.
261    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        // Dedup: active or queued recipe with the same catalog id?
270        {
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        // Stash the recipe payload before pushing the job so the driver
307        // can never observe a queued job whose payload hasn't arrived yet.
308        {
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    /// Enqueue a manifest model and bind the resulting job to a catalog
331    /// group. Same return shape as [`Self::enqueue`]; an `AlreadyPresent`
332    /// outcome still registers the existing job into the group, so a
333    /// catalog-driven enqueue that races with a manual `mold pull` of the
334    /// same companion still gets its membership tracked correctly.
335    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    /// Recipe-pull variant of [`Self::enqueue_in_group`]. Catalog id is
359    /// taken from the payload, so callers don't pass it twice.
360    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, // poisoned — best effort, the broadcast still works
375        };
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    /// Record a job's terminal status against any catalog group it belongs
407    /// to. When the group's outcome map fills (`outcomes.len() ==
408    /// job_ids.len()`), emit `CatalogReady { ok: all_completed }` and
409    /// drop the group from the ledger. Idempotent: a job not in any
410    /// group is a no-op.
411    ///
412    /// Called from `try_pull_with_retry` after each terminal arm
413    /// (Completed / Failed / Cancelled).
414    pub(crate) fn settle_for_group(&self, job_id: &str, status: JobStatus) {
415        // Two-phase to avoid emitting under the lock.
416        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            // O(groups). In practice catalogs queue 2–4 jobs each and
423            // there's rarely more than a handful of in-flight catalog
424            // entries — keeping the index direct rather than building a
425            // job_id → catalog_id reverse map.
426            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; // a job belongs to at most one group
439            }
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    /// Take ownership of the recipe payload registered for a job, if any.
451    /// The driver task calls this before running a job to determine which
452    /// driver to dispatch.
453    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    /// Cancel an in-flight or queued download. Returns `true` if a job was
489    /// found and cancelled; `false` if the id is unknown.
490    pub async fn cancel(&self, id: &str) -> bool {
491        // Check queued first — cheap.
492        {
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        // Active?
519        let active = self.active.lock().await;
520        if let Some(handle) = active.get(id) {
521            handle.abort.cancel();
522            // Driver task will observe the cancel, emit JobCancelled, and clear the job.
523            return true;
524        }
525        // Not found.
526        false
527    }
528
529    /// Called by the driver task to push a finished job into history and keep
530    /// the most recent 20.
531    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    /// Wait until either new work is notified or `shutdown` fires.
540    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
584// ── PullDriver trait + real & test implementations ──────────────────────────
585
586/// Late-readable request credential for an active manifest pull. The queue
587/// keeps the token out of public job state while allowing a duplicate desktop
588/// request to attach a valid credential even after the pull attempt began.
589pub type HfFallbackTokenProvider = Arc<dyn Fn() -> Option<String> + Send + Sync>;
590
591/// Trait that hides the HuggingFace pull behind something the tests can fake.
592///
593/// The real implementation in `HfPullDriver` calls
594/// `mold_core::download::pull_and_configure_with_callback`. Tests inject a stub.
595#[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    /// Pull with a credential provider that can be updated while the request
605    /// is active. Existing third-party drivers retain source compatibility via
606    /// this default adapter; the production HF driver overrides it.
607    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/// Trait that hides the recipe-driven (Civitai) pull. Parallel to
620/// `PullDriver` so the existing 6 manifest-driver implementations don't
621/// need to change. The driver task picks between them based on whether
622/// the queue stored a recipe payload for the job id.
623#[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
634/// Production recipe driver — calls `mold_core::download::fetch_recipe`.
635pub 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
673/// Production driver — wraps the real HF pull.
674pub 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        // Race the pull against cancellation. pull_and_configure_with_callback
726        // is not cancel-aware internally, but dropping its future on cancel
727        // aborts the underlying tokio-based HTTP calls.
728        tokio::select! {
729            res = pull => {
730                res.map(|_| ()).map_err(|e| e.to_string())
731            }
732            _ = cancel.cancelled() => {
733                // Terminal cleanup (markers + partials) happens in
734                // `try_pull_with_retry` so it covers both cancel and failure
735                // paths uniformly. Just surface the cancel error here.
736                Err("cancelled".into())
737            }
738        }
739    }
740}
741
742/// Delete partial files under `<models_dir>/<sanitized>/` for a cancelled or
743/// failed pull. Preserves any file that has a sibling `<file>.sha256-verified`
744/// marker (and the marker itself) — those represent fully-downloaded,
745/// integrity-checked content that should survive a retry/cancel cycle. Also
746/// leaves the HF cache under `~/.cache/huggingface` intact so retry is cheap.
747fn 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    // Remove `.pulling` marker first so `has_pulling_marker` returns false.
752    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/// Test seam: lets the test module observe which models `cleanup_partials_for_model`
767/// has been called with, without having to mutate process-wide `MOLD_MODELS_DIR`
768/// (which races against other tests in the same binary).
769#[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    /// Drain and return every model name `cleanup_partials_for_model` has
781    /// recorded since the last call. Tests should snapshot this before and
782    /// after their exercise to get a stable comparison.
783    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
791/// Marker suffix written after a successful SHA-256 verification.
792/// A file `foo.safetensors` is "verified" when `foo.safetensors.sha256-verified`
793/// exists next to it. Sourced from `mold-core` so the constant has a single
794/// definition shared across the pull writer and cleanup reader.
795use mold_core::download::SHA256_VERIFIED_SUFFIX;
796
797/// Walk `dir` and delete every regular file that does NOT have a sibling
798/// `<file>.sha256-verified` marker. Files that are themselves `*.sha256-verified`
799/// markers are preserved. Best-effort — I/O errors are swallowed because cleanup
800/// runs on terminal paths where we can't recover anyway.
801///
802/// If `dir` does not exist or is not a directory, this is a no-op. Subdirectories
803/// are descended into and pruned if they end up empty.
804///
805/// **Safety:** symlinks (file or directory) are always skipped. Deleting follows
806/// `entry.path().symlink_metadata()` rather than `entry.file_type()` so a
807/// symlink planted inside the models dir can never cause recursion or deletion
808/// outside the tree. Before removing a file we also canonicalize the path and
809/// assert it's still under `dir`'s canonical root as a belt-and-braces check.
810pub(crate) fn cleanup_partials_in_dir(dir: &std::path::Path) {
811    // Resolve the intended root once. If this fails the dir doesn't exist or
812    // isn't accessible — treat as no-op.
813    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        // Use symlink_metadata (does NOT follow symlinks) to detect symlinks
832        // before we decide whether to recurse or delete. A symlink
833        // (file or directory) is always skipped — we don't chase
834        // hand-placed pointers out of the models dir.
835        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            // Prune the directory if it's now empty.
850            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        // Keep marker files themselves.
861        if name.ends_with(SHA256_VERIFIED_SUFFIX) {
862            continue;
863        }
864        // Keep any file that has a sibling `<file>.sha256-verified` marker.
865        let marker = path.with_file_name(format!("{name}{SHA256_VERIFIED_SUFFIX}"));
866        if marker.exists() {
867            continue;
868        }
869        // Belt-and-braces: canonicalize the target and refuse to delete
870        // anything that escapes the models-dir root. symlink_metadata already
871        // prevents symlink traversal, but this catches pathological cases
872        // (e.g. mount binds) before we hand the path to std::fs::remove_file.
873        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
891/// Spawn the bounded-parallel driver. Returns the JoinHandle; drop or await
892/// to stop the driver (combined with `shutdown.cancel()`).
893pub 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    // Pull the recipe payload off the queue once, before the retry loop.
949    // Subsequent retries clone from this owned copy.
950    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    // Install the job as active. The pull runs inline in this function so we
957    // don't need a separate task handle — cancellation flows through `abort`.
958    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    // Move the finished job into history.
978    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    // Initial attempt + optional single retry.
998    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                // Reflect into the active-job snapshot so history includes the final state.
1015                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    // Pipe progress events through an mpsc so we can process them serially,
1099    // maintain ordering, and guarantee they drain before JobDone fires.
1100    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    // Wait for the drain task to finish (sender dropped when `pull` returned).
1137    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                // Read the current `files_done` counter from the active job
1203                // so the emitted Progress event carries truth, not a zero
1204                // placeholder. The client reducer assigns `files_done`
1205                // directly from this field, so emitting 0 mid-batch would
1206                // reset the drawer's "X of Y files" counter and cause a
1207                // visible flicker until the next FileDone event.
1208                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                // Only surface as a transient info on the active job's current_file
1249                // placeholder. The drawer shows `current_file` literally.
1250                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;