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    /// Catalog group bookkeeping. Keyed by catalog id; cleared when the
92    /// group emits `CatalogReady`. Memory-only — server restart resets
93    /// the ledger, which is fine because in-flight downloads were lost
94    /// too and the next `manifest_files_exist` poll will report any
95    /// half-done install as incomplete.
96    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    /// Already in the queue or currently active — no new job created.
110    AlreadyPresent,
111    /// Created a new job at the given 0-based position (0 = will start next).
112    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    /// Testing helper — same as `new()` but returns a non-`Arc` for
130    /// direct `await` use. Callers that need the driver running must wrap in Arc.
131    #[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    /// Returns the current active/queued/history snapshot.
141    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    /// Enqueue a model for download. Returns `(job_id, position, outcome)`.
174    /// Position 0 means the job will run immediately after the driver wakes;
175    /// N means N jobs are ahead. Duplicate enqueue returns the existing id.
176    pub async fn enqueue(
177        &self,
178        model: String,
179    ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
180        // Manifest validation up front so the caller gets a real 400 instead of a
181        // background failure.
182        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        // Check for active/queued duplicate.
188        {
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() // position shown to the user is 1-based in the drawer
225        };
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    /// Enqueue a recipe-driven download (Civitai single-file checkpoint).
236    /// Returns `(job_id, position, outcome)`. Dedupes on `payload.catalog_id`
237    /// — re-enqueuing the same recipe returns the existing job id so the
238    /// SPA's idempotent retry doesn't double-pull.
239    ///
240    /// Recipe jobs use the catalog id (e.g. `cv:618692`) as `DownloadJob.model`
241    /// so the drawer / API listing show something meaningful. The driver task
242    /// looks up the recipe payload by job id and dispatches to the recipe
243    /// driver instead of the manifest driver.
244    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        // Dedup: active or queued recipe with the same catalog id?
253        {
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        // Stash the recipe payload before pushing the job so the driver
290        // can never observe a queued job whose payload hasn't arrived yet.
291        {
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    /// Enqueue a manifest model and bind the resulting job to a catalog
314    /// group. Same return shape as [`Self::enqueue`]; an `AlreadyPresent`
315    /// outcome still registers the existing job into the group, so a
316    /// catalog-driven enqueue that races with a manual `mold pull` of the
317    /// same companion still gets its membership tracked correctly.
318    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    /// Recipe-pull variant of [`Self::enqueue_in_group`]. Catalog id is
329    /// taken from the payload, so callers don't pass it twice.
330    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, // poisoned — best effort, the broadcast still works
345        };
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    /// Record a job's terminal status against any catalog group it belongs
377    /// to. When the group's outcome map fills (`outcomes.len() ==
378    /// job_ids.len()`), emit `CatalogReady { ok: all_completed }` and
379    /// drop the group from the ledger. Idempotent: a job not in any
380    /// group is a no-op.
381    ///
382    /// Called from `try_pull_with_retry` after each terminal arm
383    /// (Completed / Failed / Cancelled).
384    pub(crate) fn settle_for_group(&self, job_id: &str, status: JobStatus) {
385        // Two-phase to avoid emitting under the lock.
386        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            // O(groups). In practice catalogs queue 2–4 jobs each and
393            // there's rarely more than a handful of in-flight catalog
394            // entries — keeping the index direct rather than building a
395            // job_id → catalog_id reverse map.
396            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; // a job belongs to at most one group
409            }
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    /// Take ownership of the recipe payload registered for a job, if any.
421    /// The driver task calls this before running a job to determine which
422    /// driver to dispatch.
423    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    /// Cancel an in-flight or queued download. Returns `true` if a job was
431    /// found and cancelled; `false` if the id is unknown.
432    pub async fn cancel(&self, id: &str) -> bool {
433        // Check queued first — cheap.
434        {
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        // Active?
457        let active = self.active.lock().await;
458        if let Some(handle) = active.get(id) {
459            handle.abort.cancel();
460            // Driver task will observe the cancel, emit JobCancelled, and clear the job.
461            return true;
462        }
463        // Not found.
464        false
465    }
466
467    /// Called by the driver task to push a finished job into history and keep
468    /// the most recent 20.
469    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    /// Wait until either new work is notified or `shutdown` fires.
478    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// ── PullDriver trait + real & test implementations ──────────────────────────
523
524/// Trait that hides the HuggingFace pull behind something the tests can fake.
525///
526/// The real implementation in `HfPullDriver` calls
527/// `mold_core::download::pull_and_configure_with_callback`. Tests inject a stub.
528#[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/// Trait that hides the recipe-driven (Civitai) pull. Parallel to
539/// `PullDriver` so the existing 6 manifest-driver implementations don't
540/// need to change. The driver task picks between them based on whether
541/// the queue stored a recipe payload for the job id.
542#[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
553/// Production recipe driver — calls `mold_core::download::fetch_recipe`.
554pub 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
592/// Production driver — wraps the real HF pull.
593pub 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        // Race the pull against cancellation. pull_and_configure_with_callback
608        // is not cancel-aware internally, but dropping its future on cancel
609        // aborts the underlying tokio-based HTTP calls.
610        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                // Terminal cleanup (markers + partials) happens in
616                // `try_pull_with_retry` so it covers both cancel and failure
617                // paths uniformly. Just surface the cancel error here.
618                Err("cancelled".into())
619            }
620        }
621    }
622}
623
624/// Delete partial files under `<models_dir>/<sanitized>/` for a cancelled or
625/// failed pull. Preserves any file that has a sibling `<file>.sha256-verified`
626/// marker (and the marker itself) — those represent fully-downloaded,
627/// integrity-checked content that should survive a retry/cancel cycle. Also
628/// leaves the HF cache under `~/.cache/huggingface` intact so retry is cheap.
629fn 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    // Remove `.pulling` marker first so `has_pulling_marker` returns false.
634    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/// Test seam: lets the test module observe which models `cleanup_partials_for_model`
649/// has been called with, without having to mutate process-wide `MOLD_MODELS_DIR`
650/// (which races against other tests in the same binary).
651#[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    /// Drain and return every model name `cleanup_partials_for_model` has
663    /// recorded since the last call. Tests should snapshot this before and
664    /// after their exercise to get a stable comparison.
665    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
673/// Marker suffix written after a successful SHA-256 verification.
674/// A file `foo.safetensors` is "verified" when `foo.safetensors.sha256-verified`
675/// exists next to it. Sourced from `mold-core` so the constant has a single
676/// definition shared across the pull writer and cleanup reader.
677use mold_core::download::SHA256_VERIFIED_SUFFIX;
678
679/// Walk `dir` and delete every regular file that does NOT have a sibling
680/// `<file>.sha256-verified` marker. Files that are themselves `*.sha256-verified`
681/// markers are preserved. Best-effort — I/O errors are swallowed because cleanup
682/// runs on terminal paths where we can't recover anyway.
683///
684/// If `dir` does not exist or is not a directory, this is a no-op. Subdirectories
685/// are descended into and pruned if they end up empty.
686///
687/// **Safety:** symlinks (file or directory) are always skipped. Deleting follows
688/// `entry.path().symlink_metadata()` rather than `entry.file_type()` so a
689/// symlink planted inside the models dir can never cause recursion or deletion
690/// outside the tree. Before removing a file we also canonicalize the path and
691/// assert it's still under `dir`'s canonical root as a belt-and-braces check.
692pub(crate) fn cleanup_partials_in_dir(dir: &std::path::Path) {
693    // Resolve the intended root once. If this fails the dir doesn't exist or
694    // isn't accessible — treat as no-op.
695    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        // Use symlink_metadata (does NOT follow symlinks) to detect symlinks
714        // before we decide whether to recurse or delete. A symlink
715        // (file or directory) is always skipped — we don't chase
716        // hand-placed pointers out of the models dir.
717        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            // Prune the directory if it's now empty.
732            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        // Keep marker files themselves.
743        if name.ends_with(SHA256_VERIFIED_SUFFIX) {
744            continue;
745        }
746        // Keep any file that has a sibling `<file>.sha256-verified` marker.
747        let marker = path.with_file_name(format!("{name}{SHA256_VERIFIED_SUFFIX}"));
748        if marker.exists() {
749            continue;
750        }
751        // Belt-and-braces: canonicalize the target and refuse to delete
752        // anything that escapes the models-dir root. symlink_metadata already
753        // prevents symlink traversal, but this catches pathological cases
754        // (e.g. mount binds) before we hand the path to std::fs::remove_file.
755        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
773/// Spawn the bounded-parallel driver. Returns the JoinHandle; drop or await
774/// to stop the driver (combined with `shutdown.cancel()`).
775pub 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    // Pull the recipe payload off the queue once, before the retry loop.
831    // Subsequent retries clone from this owned copy.
832    let recipe_payload = queue.take_recipe_payload(&job.id);
833
834    // Install the job as active. The pull runs inline in this function so we
835    // don't need a separate task handle — cancellation flows through `abort`.
836    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    // Move the finished job into history.
854    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    // Initial attempt + optional single retry.
873    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                // Reflect into the active-job snapshot so history includes the final state.
889                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    // Pipe progress events through an mpsc so we can process them serially,
972    // maintain ordering, and guarantee they drain before JobDone fires.
973    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    // Wait for the drain task to finish (sender dropped when `pull` returned).
1001    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                // Read the current `files_done` counter from the active job
1067                // so the emitted Progress event carries truth, not a zero
1068                // placeholder. The client reducer assigns `files_done`
1069                // directly from this field, so emitting 0 mid-batch would
1070                // reset the drawer's "X of Y files" counter and cause a
1071                // visible flicker until the next FileDone event.
1072                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                // Only surface as a transient info on the active job's current_file
1113                // placeholder. The drawer shows `current_file` literally.
1114                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;