Skip to main content

mold_server/
downloads.rs

1//! Single-writer 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/// Active download handle.
50pub struct ActiveHandle {
51    pub job: DownloadJob,
52    pub abort: CancellationToken,
53}
54
55/// Membership ledger for a catalog entry's downloads. Tracks every job
56/// (primary + companions) that belongs to a single user-visible catalog
57/// id (`cv:1759168`, an HF entry, etc.) so the queue knows when the *whole*
58/// entry is ready — not just when one of its jobs finished.
59///
60/// Why this exists: `catalog_api::post_catalog_download` enqueues companions
61/// (CLIP-L/G, VAE) before the primary checkpoint. Pre-C, the SPA refreshed
62/// its model list on the primary's `JobDone` event, which sometimes fired
63/// before companions were on disk — that's the "model sometimes doesn't
64/// show up after download" bug. Now the SPA listens for `CatalogReady`,
65/// emitted exactly once when the last job in the group settles.
66#[derive(Debug, Clone)]
67struct CatalogGroup {
68    catalog_id: String,
69    /// Every job that must settle before the group is ready.
70    job_ids: std::collections::HashSet<String>,
71    /// Final status keyed by job id. Group is ready when
72    /// `outcomes.len() == job_ids.len()`.
73    outcomes: HashMap<String, JobStatus>,
74}
75
76pub struct DownloadQueue {
77    active: AsyncMutex<Option<ActiveHandle>>,
78    queued: StdMutex<VecDeque<DownloadJob>>,
79    history: StdMutex<VecDeque<DownloadJob>>,
80    events: broadcast::Sender<DownloadEvent>,
81    /// Wakes the driver task when new work arrives.
82    notify: Notify,
83    /// Recipe payloads indexed by job id. Populated by `enqueue_recipe`,
84    /// drained by the driver task before running the job. Manifest jobs
85    /// have no entry here and fall through to the `PullDriver` path.
86    recipe_payloads: StdMutex<HashMap<String, RecipePayload>>,
87    /// Catalog group bookkeeping. Keyed by catalog id; cleared when the
88    /// group emits `CatalogReady`. Memory-only — server restart resets
89    /// the ledger, which is fine because in-flight downloads were lost
90    /// too and the next `manifest_files_exist` poll will report any
91    /// half-done install as incomplete.
92    groups: StdMutex<HashMap<String, CatalogGroup>>,
93}
94
95#[derive(Debug, thiserror::Error)]
96pub enum EnqueueError {
97    #[error("unknown model '{0}'. Run 'mold list' to see available models.")]
98    UnknownModel(String),
99    #[error("download queue lock poisoned")]
100    LockPoisoned,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum EnqueueOutcome {
105    /// Already in the queue or currently active — no new job created.
106    AlreadyPresent,
107    /// Created a new job at the given 0-based position (0 = will start next).
108    Created,
109}
110
111impl DownloadQueue {
112    pub fn new() -> Arc<Self> {
113        let (events, _rx) = broadcast::channel(EVENT_BUFFER);
114        Arc::new(Self {
115            active: AsyncMutex::new(None),
116            queued: StdMutex::new(VecDeque::new()),
117            history: StdMutex::new(VecDeque::new()),
118            events,
119            notify: Notify::new(),
120            recipe_payloads: StdMutex::new(HashMap::new()),
121            groups: StdMutex::new(HashMap::new()),
122        })
123    }
124
125    /// Testing helper — same as `new()` but returns a non-`Arc` for
126    /// direct `await` use. Callers that need the driver running must wrap in Arc.
127    #[cfg(test)]
128    pub fn new_for_test() -> Arc<Self> {
129        Self::new()
130    }
131
132    pub fn subscribe(&self) -> broadcast::Receiver<DownloadEvent> {
133        self.events.subscribe()
134    }
135
136    /// Returns the current active/queued/history snapshot.
137    pub async fn listing(&self) -> DownloadsListing {
138        let active = self.active.lock().await.as_ref().map(|a| a.job.clone());
139        let queued: Vec<DownloadJob> = self
140            .queued
141            .lock()
142            .expect("queued lock poisoned")
143            .iter()
144            .cloned()
145            .collect();
146        let history: Vec<DownloadJob> = self
147            .history
148            .lock()
149            .expect("history lock poisoned")
150            .iter()
151            .cloned()
152            .collect();
153        DownloadsListing {
154            active,
155            queued,
156            history,
157        }
158    }
159
160    /// Enqueue a model for download. Returns `(job_id, position, outcome)`.
161    /// Position 0 means the job will run immediately after the driver wakes;
162    /// N means N jobs are ahead. Duplicate enqueue returns the existing id.
163    pub async fn enqueue(
164        &self,
165        model: String,
166    ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
167        // Manifest validation up front so the caller gets a real 400 instead of a
168        // background failure.
169        let canonical = mold_core::manifest::resolve_model_name(&model);
170        if mold_core::manifest::find_manifest(&canonical).is_none() {
171            return Err(EnqueueError::UnknownModel(model));
172        }
173
174        // Check for active/queued duplicate.
175        {
176            let active = self.active.lock().await;
177            if let Some(handle) = active.as_ref() {
178                if handle.job.model == canonical {
179                    return Ok((handle.job.id.clone(), 0, EnqueueOutcome::AlreadyPresent));
180                }
181            }
182        }
183        {
184            let queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
185            if let Some((idx, existing)) = queued
186                .iter()
187                .enumerate()
188                .find(|(_, j)| j.model == canonical)
189            {
190                return Ok((existing.id.clone(), idx + 1, EnqueueOutcome::AlreadyPresent));
191            }
192        }
193
194        let id = uuid::Uuid::new_v4().to_string();
195        let job = DownloadJob {
196            id: id.clone(),
197            model: canonical.clone(),
198            catalog_id: None,
199            status: JobStatus::Queued,
200            files_done: 0,
201            files_total: 0,
202            bytes_done: 0,
203            bytes_total: 0,
204            current_file: None,
205            started_at: None,
206            completed_at: None,
207            error: None,
208        };
209
210        let position = {
211            let mut queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
212            queued.push_back(job);
213            queued.len() // position shown to the user is 1-based in the drawer
214        };
215        let _ = self.events.send(DownloadEvent::Enqueued {
216            id: id.clone(),
217            model: canonical,
218            position,
219        });
220        self.notify.notify_one();
221        Ok((id, position, EnqueueOutcome::Created))
222    }
223
224    /// Enqueue a recipe-driven download (Civitai single-file checkpoint).
225    /// Returns `(job_id, position, outcome)`. Dedupes on `payload.catalog_id`
226    /// — re-enqueuing the same recipe returns the existing job id so the
227    /// SPA's idempotent retry doesn't double-pull.
228    ///
229    /// Recipe jobs use the catalog id (e.g. `cv:618692`) as `DownloadJob.model`
230    /// so the drawer / API listing show something meaningful. The driver task
231    /// looks up the recipe payload by job id and dispatches to the recipe
232    /// driver instead of the manifest driver.
233    pub async fn enqueue_recipe(
234        &self,
235        payload: RecipePayload,
236    ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
237        if payload.catalog_id.trim().is_empty() {
238            return Err(EnqueueError::UnknownModel(payload.catalog_id));
239        }
240
241        // Dedup: active or queued recipe with the same catalog id?
242        {
243            let active = self.active.lock().await;
244            if let Some(handle) = active.as_ref() {
245                if handle.job.model == payload.catalog_id {
246                    return Ok((handle.job.id.clone(), 0, EnqueueOutcome::AlreadyPresent));
247                }
248            }
249        }
250        {
251            let queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
252            if let Some((idx, existing)) = queued
253                .iter()
254                .enumerate()
255                .find(|(_, j)| j.model == payload.catalog_id)
256            {
257                return Ok((existing.id.clone(), idx + 1, EnqueueOutcome::AlreadyPresent));
258            }
259        }
260
261        let id = uuid::Uuid::new_v4().to_string();
262        let job = DownloadJob {
263            id: id.clone(),
264            model: payload.catalog_id.clone(),
265            catalog_id: Some(payload.catalog_id.clone()),
266            status: JobStatus::Queued,
267            files_done: 0,
268            files_total: payload.files.len(),
269            bytes_done: 0,
270            bytes_total: payload.files.iter().filter_map(|f| f.size_bytes).sum(),
271            current_file: None,
272            started_at: None,
273            completed_at: None,
274            error: None,
275        };
276
277        // Stash the recipe payload before pushing the job so the driver
278        // can never observe a queued job whose payload hasn't arrived yet.
279        {
280            let mut payloads = self
281                .recipe_payloads
282                .lock()
283                .map_err(|_| EnqueueError::LockPoisoned)?;
284            payloads.insert(id.clone(), payload.clone());
285        }
286
287        let position = {
288            let mut queued = self.queued.lock().map_err(|_| EnqueueError::LockPoisoned)?;
289            queued.push_back(job);
290            queued.len()
291        };
292        let _ = self.events.send(DownloadEvent::Enqueued {
293            id: id.clone(),
294            model: payload.catalog_id,
295            position,
296        });
297        self.notify.notify_one();
298        Ok((id, position, EnqueueOutcome::Created))
299    }
300
301    /// Enqueue a manifest model and bind the resulting job to a catalog
302    /// group. Same return shape as [`Self::enqueue`]; an `AlreadyPresent`
303    /// outcome still registers the existing job into the group, so a
304    /// catalog-driven enqueue that races with a manual `mold pull` of the
305    /// same companion still gets its membership tracked correctly.
306    pub async fn enqueue_in_group(
307        &self,
308        model: String,
309        catalog_id: &str,
310    ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
311        let (id, pos, outcome) = self.enqueue(model).await?;
312        self.register_in_group(catalog_id, &id);
313        Ok((id, pos, outcome))
314    }
315
316    /// Recipe-pull variant of [`Self::enqueue_in_group`]. Catalog id is
317    /// taken from the payload, so callers don't pass it twice.
318    pub async fn enqueue_recipe_in_group(
319        &self,
320        payload: RecipePayload,
321        catalog_id: &str,
322    ) -> Result<(String, usize, EnqueueOutcome), EnqueueError> {
323        let (id, pos, outcome) = self.enqueue_recipe(payload).await?;
324        self.register_in_group(catalog_id, &id);
325        Ok((id, pos, outcome))
326    }
327
328    fn register_in_group(&self, catalog_id: &str, job_id: &str) {
329        self.tag_job_with_catalog_id(catalog_id, job_id);
330        let mut groups = match self.groups.lock() {
331            Ok(g) => g,
332            Err(_) => return, // poisoned — best effort, the broadcast still works
333        };
334        let entry = groups
335            .entry(catalog_id.to_string())
336            .or_insert_with(|| CatalogGroup {
337                catalog_id: catalog_id.to_string(),
338                job_ids: std::collections::HashSet::new(),
339                outcomes: HashMap::new(),
340            });
341        entry.job_ids.insert(job_id.to_string());
342    }
343
344    fn tag_job_with_catalog_id(&self, catalog_id: &str, job_id: &str) {
345        if let Ok(mut queued) = self.queued.lock() {
346            if let Some(job) = queued.iter_mut().find(|job| job.id == job_id) {
347                job.catalog_id = Some(catalog_id.to_string());
348                return;
349            }
350        }
351        if let Ok(mut history) = self.history.lock() {
352            if let Some(job) = history.iter_mut().find(|job| job.id == job_id) {
353                job.catalog_id = Some(catalog_id.to_string());
354                return;
355            }
356        }
357        if let Ok(mut active) = self.active.try_lock() {
358            if let Some(handle) = active.as_mut().filter(|handle| handle.job.id == job_id) {
359                handle.job.catalog_id = Some(catalog_id.to_string());
360            }
361        }
362    }
363
364    /// Record a job's terminal status against any catalog group it belongs
365    /// to. When the group's outcome map fills (`outcomes.len() ==
366    /// job_ids.len()`), emit `CatalogReady { ok: all_completed }` and
367    /// drop the group from the ledger. Idempotent: a job not in any
368    /// group is a no-op.
369    ///
370    /// Called from `try_pull_with_retry` after each terminal arm
371    /// (Completed / Failed / Cancelled).
372    pub(crate) fn settle_for_group(&self, job_id: &str, status: JobStatus) {
373        // Two-phase to avoid emitting under the lock.
374        let to_emit = {
375            let mut groups = match self.groups.lock() {
376                Ok(g) => g,
377                Err(_) => return,
378            };
379            let mut emit_for: Option<(String, bool)> = None;
380            // O(groups). In practice catalogs queue 2–4 jobs each and
381            // there's rarely more than a handful of in-flight catalog
382            // entries — keeping the index direct rather than building a
383            // job_id → catalog_id reverse map.
384            for group in groups.values_mut() {
385                if !group.job_ids.contains(job_id) {
386                    continue;
387                }
388                group.outcomes.insert(job_id.to_string(), status);
389                if group.outcomes.len() == group.job_ids.len() {
390                    let ok = group
391                        .outcomes
392                        .values()
393                        .all(|s| matches!(s, JobStatus::Completed));
394                    emit_for = Some((group.catalog_id.clone(), ok));
395                }
396                break; // a job belongs to at most one group
397            }
398            if let Some((id, _)) = &emit_for {
399                groups.remove(id);
400            }
401            emit_for
402        };
403        if let Some((id, ok)) = to_emit {
404            self.emit(DownloadEvent::CatalogReady { id, ok });
405        }
406    }
407
408    /// Take ownership of the recipe payload registered for a job, if any.
409    /// The driver task calls this before running a job to determine which
410    /// driver to dispatch.
411    pub(crate) fn take_recipe_payload(&self, job_id: &str) -> Option<RecipePayload> {
412        self.recipe_payloads
413            .lock()
414            .ok()
415            .and_then(|mut p| p.remove(job_id))
416    }
417
418    /// Cancel an in-flight or queued download. Returns `true` if a job was
419    /// found and cancelled; `false` if the id is unknown.
420    pub async fn cancel(&self, id: &str) -> bool {
421        // Check queued first — cheap.
422        {
423            let mut queued = self.queued.lock().expect("queued lock poisoned");
424            if let Some(pos) = queued.iter().position(|j| j.id == id) {
425                queued.remove(pos);
426                drop(queued);
427                let _ = self
428                    .events
429                    .send(DownloadEvent::Dequeued { id: id.to_string() });
430                return true;
431            }
432        }
433        // Active?
434        let active = self.active.lock().await;
435        if let Some(handle) = active.as_ref() {
436            if handle.job.id == id {
437                handle.abort.cancel();
438                // Driver task will observe the cancel, emit JobCancelled, and clear `active`.
439                return true;
440            }
441        }
442        // Not found.
443        false
444    }
445
446    /// Called by the driver task to push a finished job into history and keep
447    /// the most recent 20.
448    pub(crate) fn push_history(&self, job: DownloadJob) {
449        let mut history = self.history.lock().expect("history lock poisoned");
450        if history.len() >= HISTORY_CAP {
451            history.pop_front();
452        }
453        history.push_back(job);
454    }
455
456    /// Wait until either new work is notified or `shutdown` fires.
457    pub(crate) async fn wait_for_work(&self, shutdown: &CancellationToken) {
458        tokio::select! {
459            _ = self.notify.notified() => {},
460            _ = shutdown.cancelled() => {},
461        }
462    }
463
464    pub(crate) fn take_next_queued(&self) -> Option<DownloadJob> {
465        self.queued
466            .lock()
467            .expect("queued lock poisoned")
468            .pop_front()
469    }
470
471    pub(crate) async fn set_active(&self, handle: ActiveHandle) {
472        *self.active.lock().await = Some(handle);
473    }
474
475    pub(crate) async fn clear_active(&self) -> Option<ActiveHandle> {
476        self.active.lock().await.take()
477    }
478
479    pub(crate) async fn with_active<F, R>(&self, f: F) -> Option<R>
480    where
481        F: FnOnce(&mut DownloadJob) -> R,
482    {
483        let mut active = self.active.lock().await;
484        active.as_mut().map(|a| f(&mut a.job))
485    }
486
487    pub(crate) fn emit(&self, event: DownloadEvent) {
488        let _ = self.events.send(event);
489    }
490}
491
492// ── PullDriver trait + real & test implementations ──────────────────────────
493
494/// Trait that hides the HuggingFace pull behind something the tests can fake.
495///
496/// The real implementation in `HfPullDriver` calls
497/// `mold_core::download::pull_and_configure_with_callback`. Tests inject a stub.
498#[async_trait::async_trait]
499pub trait PullDriver: Send + Sync + 'static {
500    async fn pull(
501        &self,
502        model: &str,
503        on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
504        cancel: CancellationToken,
505    ) -> Result<(), String>;
506}
507
508/// Trait that hides the recipe-driven (Civitai) pull. Parallel to
509/// `PullDriver` so the existing 6 manifest-driver implementations don't
510/// need to change. The driver task picks between them based on whether
511/// the queue stored a recipe payload for the job id.
512#[async_trait::async_trait]
513pub trait RecipePullDriver: Send + Sync + 'static {
514    async fn pull(
515        &self,
516        payload: RecipePayload,
517        models_dir: std::path::PathBuf,
518        on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
519        cancel: CancellationToken,
520    ) -> Result<(), String>;
521}
522
523/// Production recipe driver — calls `mold_core::download::fetch_recipe`.
524pub struct CivitaiRecipeDriver;
525
526#[async_trait::async_trait]
527impl RecipePullDriver for CivitaiRecipeDriver {
528    async fn pull(
529        &self,
530        payload: RecipePayload,
531        models_dir: std::path::PathBuf,
532        on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
533        cancel: CancellationToken,
534    ) -> Result<(), String> {
535        let on_progress: mold_core::download::DownloadProgressCallback =
536            std::sync::Arc::from(on_progress);
537        let opts = mold_core::download::PullOptions::default();
538        let files: Vec<mold_core::download::RecipeFetchFile<'_>> = payload
539            .files
540            .iter()
541            .map(|f| mold_core::download::RecipeFetchFile {
542                url: f.url.as_str(),
543                dest: f.dest.as_str(),
544                sha256: f.sha256.as_deref(),
545                size_bytes: f.size_bytes,
546            })
547            .collect();
548        tokio::select! {
549            res = mold_core::download::fetch_recipe(
550                &payload.catalog_id,
551                &files,
552                payload.auth.clone(),
553                &models_dir,
554                Some(on_progress),
555                &opts,
556            ) => res.map(|_| ()).map_err(|e| e.to_string()),
557            _ = cancel.cancelled() => Err("cancelled".into()),
558        }
559    }
560}
561
562/// Production driver — wraps the real HF pull.
563pub struct HfPullDriver;
564
565#[async_trait::async_trait]
566impl PullDriver for HfPullDriver {
567    async fn pull(
568        &self,
569        model: &str,
570        on_progress: Box<dyn Fn(mold_core::download::DownloadProgressEvent) + Send + Sync>,
571        cancel: CancellationToken,
572    ) -> Result<(), String> {
573        let on_progress: mold_core::download::DownloadProgressCallback =
574            std::sync::Arc::from(on_progress);
575        let opts = mold_core::download::PullOptions::default();
576        let model = model.to_string();
577        // Race the pull against cancellation. pull_and_configure_with_callback
578        // is not cancel-aware internally, but dropping its future on cancel
579        // aborts the underlying tokio-based HTTP calls.
580        tokio::select! {
581            res = mold_core::download::pull_and_configure_with_callback(&model, on_progress, &opts) => {
582                res.map(|_| ()).map_err(|e| e.to_string())
583            }
584            _ = cancel.cancelled() => {
585                // Terminal cleanup (markers + partials) happens in
586                // `try_pull_with_retry` so it covers both cancel and failure
587                // paths uniformly. Just surface the cancel error here.
588                Err("cancelled".into())
589            }
590        }
591    }
592}
593
594/// Delete partial files under `<models_dir>/<sanitized>/` for a cancelled or
595/// failed pull. Preserves any file that has a sibling `<file>.sha256-verified`
596/// marker (and the marker itself) — those represent fully-downloaded,
597/// integrity-checked content that should survive a retry/cancel cycle. Also
598/// leaves the HF cache under `~/.cache/huggingface` intact so retry is cheap.
599fn cleanup_partials_for_model(model: &str) {
600    use mold_core::manifest::resolve_model_name;
601    let canonical = resolve_model_name(model);
602    let sanitized = canonical.replace(':', "-");
603    // Remove `.pulling` marker first so `has_pulling_marker` returns false.
604    mold_core::download::remove_pulling_marker(&canonical);
605    #[cfg(test)]
606    test_hooks::record_cleanup(&canonical);
607    if let Ok(models_dir) = std::env::var("MOLD_MODELS_DIR") {
608        let target = std::path::PathBuf::from(models_dir).join(&sanitized);
609        cleanup_partials_in_dir(&target);
610        return;
611    }
612    if let Some(home) = dirs::home_dir() {
613        let target = home.join(".mold/models").join(&sanitized);
614        cleanup_partials_in_dir(&target);
615    }
616}
617
618/// Test seam: lets the test module observe which models `cleanup_partials_for_model`
619/// has been called with, without having to mutate process-wide `MOLD_MODELS_DIR`
620/// (which races against other tests in the same binary).
621#[cfg(test)]
622pub(crate) mod test_hooks {
623    use std::sync::Mutex;
624    static CLEANUPS: Mutex<Vec<String>> = Mutex::new(Vec::new());
625
626    pub(crate) fn record_cleanup(model: &str) {
627        if let Ok(mut v) = CLEANUPS.lock() {
628            v.push(model.to_string());
629        }
630    }
631
632    /// Drain and return every model name `cleanup_partials_for_model` has
633    /// recorded since the last call. Tests should snapshot this before and
634    /// after their exercise to get a stable comparison.
635    pub fn drain_cleanups() -> Vec<String> {
636        CLEANUPS
637            .lock()
638            .map(|mut v| std::mem::take(&mut *v))
639            .unwrap_or_default()
640    }
641}
642
643/// Marker suffix written after a successful SHA-256 verification.
644/// A file `foo.safetensors` is "verified" when `foo.safetensors.sha256-verified`
645/// exists next to it. Sourced from `mold-core` so the constant has a single
646/// definition shared across the pull writer and cleanup reader.
647use mold_core::download::SHA256_VERIFIED_SUFFIX;
648
649/// Walk `dir` and delete every regular file that does NOT have a sibling
650/// `<file>.sha256-verified` marker. Files that are themselves `*.sha256-verified`
651/// markers are preserved. Best-effort — I/O errors are swallowed because cleanup
652/// runs on terminal paths where we can't recover anyway.
653///
654/// If `dir` does not exist or is not a directory, this is a no-op. Subdirectories
655/// are descended into and pruned if they end up empty.
656///
657/// **Safety:** symlinks (file or directory) are always skipped. Deleting follows
658/// `entry.path().symlink_metadata()` rather than `entry.file_type()` so a
659/// symlink planted inside the models dir can never cause recursion or deletion
660/// outside the tree. Before removing a file we also canonicalize the path and
661/// assert it's still under `dir`'s canonical root as a belt-and-braces check.
662pub(crate) fn cleanup_partials_in_dir(dir: &std::path::Path) {
663    // Resolve the intended root once. If this fails the dir doesn't exist or
664    // isn't accessible — treat as no-op.
665    let root = match dir.canonicalize() {
666        Ok(p) => p,
667        Err(_) => return,
668    };
669    cleanup_partials_in_dir_under_root(dir, &root);
670}
671
672fn cleanup_partials_in_dir_under_root(dir: &std::path::Path, root: &std::path::Path) {
673    if !dir.is_dir() {
674        return;
675    }
676    let entries = match std::fs::read_dir(dir) {
677        Ok(e) => e,
678        Err(_) => return,
679    };
680    for entry in entries.flatten() {
681        let path = entry.path();
682
683        // Use symlink_metadata (does NOT follow symlinks) to detect symlinks
684        // before we decide whether to recurse or delete. A symlink
685        // (file or directory) is always skipped — we don't chase
686        // hand-placed pointers out of the models dir.
687        let meta = match path.symlink_metadata() {
688            Ok(m) => m,
689            Err(_) => continue,
690        };
691        let file_type = meta.file_type();
692        if file_type.is_symlink() {
693            tracing::warn!(
694                path = %path.display(),
695                "cleanup_partials: skipping symlink (not following)",
696            );
697            continue;
698        }
699        if file_type.is_dir() {
700            cleanup_partials_in_dir_under_root(&path, root);
701            // Prune the directory if it's now empty.
702            let _ = std::fs::remove_dir(&path);
703            continue;
704        }
705        if !file_type.is_file() {
706            continue;
707        }
708        let name = match path.file_name().and_then(|n| n.to_str()) {
709            Some(n) => n,
710            None => continue,
711        };
712        // Keep marker files themselves.
713        if name.ends_with(SHA256_VERIFIED_SUFFIX) {
714            continue;
715        }
716        // Keep any file that has a sibling `<file>.sha256-verified` marker.
717        let marker = path.with_file_name(format!("{name}{SHA256_VERIFIED_SUFFIX}"));
718        if marker.exists() {
719            continue;
720        }
721        // Belt-and-braces: canonicalize the target and refuse to delete
722        // anything that escapes the models-dir root. symlink_metadata already
723        // prevents symlink traversal, but this catches pathological cases
724        // (e.g. mount binds) before we hand the path to std::fs::remove_file.
725        match path.canonicalize() {
726            Ok(canon) => {
727                if !canon.starts_with(root) {
728                    tracing::warn!(
729                        path = %path.display(),
730                        canon = %canon.display(),
731                        root = %root.display(),
732                        "cleanup_partials: refusing to delete file outside models dir",
733                    );
734                    continue;
735                }
736            }
737            Err(_) => continue,
738        }
739        let _ = std::fs::remove_file(&path);
740    }
741}
742
743/// Spawn the single driver task. Returns the JoinHandle; drop or await to
744/// stop the driver (combined with `shutdown.cancel()`).
745pub fn spawn_driver(
746    queue: Arc<DownloadQueue>,
747    driver: Arc<dyn PullDriver>,
748    recipe_driver: Arc<dyn RecipePullDriver>,
749    models_dir: std::path::PathBuf,
750    shutdown: CancellationToken,
751) -> tokio::task::JoinHandle<()> {
752    tokio::spawn(async move {
753        tracing::info!("download queue driver started");
754        loop {
755            queue.wait_for_work(&shutdown).await;
756            if shutdown.is_cancelled() {
757                break;
758            }
759            while let Some(mut job) = queue.take_next_queued() {
760                if shutdown.is_cancelled() {
761                    break;
762                }
763                run_one_job(&queue, &driver, &recipe_driver, &models_dir, &mut job).await;
764                if shutdown.is_cancelled() {
765                    break;
766                }
767            }
768        }
769        tracing::info!("download queue driver exiting");
770    })
771}
772
773async fn run_one_job(
774    queue: &Arc<DownloadQueue>,
775    driver: &Arc<dyn PullDriver>,
776    recipe_driver: &Arc<dyn RecipePullDriver>,
777    models_dir: &std::path::Path,
778    job: &mut DownloadJob,
779) {
780    job.status = JobStatus::Active;
781    job.started_at = Some(now_ms());
782    let cancel = CancellationToken::new();
783    let handle_job = job.clone();
784
785    // Pull the recipe payload off the queue once, before the retry loop.
786    // Subsequent retries clone from this owned copy.
787    let recipe_payload = queue.take_recipe_payload(&job.id);
788
789    // Install the job as active. The pull runs inline in this function so we
790    // don't need a separate task handle — cancellation flows through `abort`.
791    let active_handle = ActiveHandle {
792        job: handle_job,
793        abort: cancel.clone(),
794    };
795    queue.set_active(active_handle).await;
796
797    let _ = try_pull_with_retry(
798        queue,
799        driver,
800        recipe_driver,
801        models_dir,
802        recipe_payload.as_ref(),
803        job,
804        cancel.clone(),
805    )
806    .await;
807
808    // Move the finished job into history.
809    let final_job = queue
810        .with_active(|a| a.clone())
811        .await
812        .unwrap_or_else(|| job.clone());
813    queue.clear_active().await;
814    queue.push_history(final_job);
815}
816
817#[allow(clippy::too_many_arguments)]
818async fn try_pull_with_retry(
819    queue: &Arc<DownloadQueue>,
820    driver: &Arc<dyn PullDriver>,
821    recipe_driver: &Arc<dyn RecipePullDriver>,
822    models_dir: &std::path::Path,
823    recipe_payload: Option<&RecipePayload>,
824    job: &mut DownloadJob,
825    cancel: CancellationToken,
826) -> Result<(), ()> {
827    // Initial attempt + optional single retry.
828    for attempt in 0..=1u8 {
829        let result = run_single_attempt(
830            queue,
831            driver,
832            recipe_driver,
833            models_dir,
834            recipe_payload,
835            job,
836            cancel.clone(),
837        )
838        .await;
839        match result {
840            Ok(()) => {
841                job.status = JobStatus::Completed;
842                job.completed_at = Some(now_ms());
843                // Reflect into the active-job snapshot so history includes the final state.
844                queue
845                    .with_active(|a| {
846                        *a = job.clone();
847                    })
848                    .await;
849                queue.emit(DownloadEvent::JobDone {
850                    id: job.id.clone(),
851                    model: job.model.clone(),
852                });
853                queue.settle_for_group(&job.id, JobStatus::Completed);
854                return Ok(());
855            }
856            Err(AttemptError::Cancelled) => {
857                job.status = JobStatus::Cancelled;
858                job.completed_at = Some(now_ms());
859                queue
860                    .with_active(|a| {
861                        *a = job.clone();
862                    })
863                    .await;
864                cleanup_partials_for_model(&job.model);
865                queue.emit(DownloadEvent::JobCancelled { id: job.id.clone() });
866                queue.settle_for_group(&job.id, JobStatus::Cancelled);
867                return Err(());
868            }
869            Err(AttemptError::Failed(msg)) => {
870                if attempt == 0 {
871                    tracing::warn!(model = %job.model, "pull attempt 1 failed: {msg} — retrying in 5s");
872                    tokio::select! {
873                        _ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {},
874                        _ = cancel.cancelled() => {
875                            job.status = JobStatus::Cancelled;
876                            job.completed_at = Some(now_ms());
877                            queue
878                                .with_active(|a| {
879                                    *a = job.clone();
880                                })
881                                .await;
882                            cleanup_partials_for_model(&job.model);
883                            queue.emit(DownloadEvent::JobCancelled { id: job.id.clone() });
884                            queue.settle_for_group(&job.id, JobStatus::Cancelled);
885                            return Err(());
886                        }
887                    }
888                    continue;
889                }
890                job.status = JobStatus::Failed;
891                job.error = Some(msg.clone());
892                job.completed_at = Some(now_ms());
893                queue
894                    .with_active(|a| {
895                        *a = job.clone();
896                    })
897                    .await;
898                cleanup_partials_for_model(&job.model);
899                queue.emit(DownloadEvent::JobFailed {
900                    id: job.id.clone(),
901                    error: msg,
902                });
903                queue.settle_for_group(&job.id, JobStatus::Failed);
904                return Err(());
905            }
906        }
907    }
908    Err(())
909}
910
911enum AttemptError {
912    Cancelled,
913    Failed(String),
914}
915
916#[allow(clippy::too_many_arguments)]
917async fn run_single_attempt(
918    queue: &Arc<DownloadQueue>,
919    driver: &Arc<dyn PullDriver>,
920    recipe_driver: &Arc<dyn RecipePullDriver>,
921    models_dir: &std::path::Path,
922    recipe_payload: Option<&RecipePayload>,
923    job: &mut DownloadJob,
924    cancel: CancellationToken,
925) -> Result<(), AttemptError> {
926    // Pipe progress events through an mpsc so we can process them serially,
927    // maintain ordering, and guarantee they drain before JobDone fires.
928    let (tx, mut rx) =
929        tokio::sync::mpsc::unbounded_channel::<mold_core::download::DownloadProgressEvent>();
930    let on_progress = Box::new(move |evt: mold_core::download::DownloadProgressEvent| {
931        let _ = tx.send(evt);
932    });
933
934    let queue_for_drain = queue.clone();
935    let job_id = job.id.clone();
936    let drain_handle = tokio::spawn(async move {
937        while let Some(evt) = rx.recv().await {
938            translate_event(&queue_for_drain, &job_id, evt).await;
939        }
940    });
941
942    let result = match recipe_payload {
943        Some(payload) => {
944            recipe_driver
945                .pull(
946                    payload.clone(),
947                    models_dir.to_path_buf(),
948                    on_progress,
949                    cancel.clone(),
950                )
951                .await
952        }
953        None => driver.pull(&job.model, on_progress, cancel.clone()).await,
954    };
955    // Wait for the drain task to finish (sender dropped when `pull` returned).
956    let _ = drain_handle.await;
957
958    match result {
959        Ok(()) => Ok(()),
960        Err(msg) if cancel.is_cancelled() => {
961            let _ = msg;
962            Err(AttemptError::Cancelled)
963        }
964        Err(msg) => Err(AttemptError::Failed(msg)),
965    }
966}
967
968async fn translate_event(
969    queue: &Arc<DownloadQueue>,
970    job_id: &str,
971    evt: mold_core::download::DownloadProgressEvent,
972) {
973    use mold_core::download::DownloadProgressEvent as P;
974    let queue = queue.clone();
975    let id = job_id.to_string();
976    {
977        match evt {
978            P::FileStart {
979                total_files,
980                batch_bytes_total,
981                filename,
982                file_index,
983                ..
984            } => {
985                let emitted_started = queue
986                    .with_active(|j| {
987                        if j.files_total == 0 {
988                            j.files_total = total_files;
989                            j.bytes_total = batch_bytes_total;
990                            true
991                        } else {
992                            false
993                        }
994                    })
995                    .await
996                    .unwrap_or(false);
997                if emitted_started {
998                    queue.emit(DownloadEvent::Started {
999                        id: id.clone(),
1000                        files_total: total_files,
1001                        bytes_total: batch_bytes_total,
1002                    });
1003                }
1004                let _ = file_index;
1005                queue
1006                    .with_active(|j| {
1007                        j.current_file = Some(filename.clone());
1008                    })
1009                    .await;
1010            }
1011            P::FileProgress {
1012                filename,
1013                bytes_downloaded,
1014                batch_bytes_downloaded,
1015                batch_bytes_total,
1016                file_index,
1017                bytes_total,
1018                ..
1019            } => {
1020                let _ = (bytes_downloaded, bytes_total, file_index);
1021                // Read the current `files_done` counter from the active job
1022                // so the emitted Progress event carries truth, not a zero
1023                // placeholder. The client reducer assigns `files_done`
1024                // directly from this field, so emitting 0 mid-batch would
1025                // reset the drawer's "X of Y files" counter and cause a
1026                // visible flicker until the next FileDone event.
1027                let files_done = queue
1028                    .with_active(|j| {
1029                        j.bytes_done = batch_bytes_downloaded;
1030                        if j.bytes_total == 0 {
1031                            j.bytes_total = batch_bytes_total;
1032                        }
1033                        j.current_file = Some(filename.clone());
1034                        j.files_done
1035                    })
1036                    .await
1037                    .unwrap_or(0);
1038                queue.emit(DownloadEvent::Progress {
1039                    id: id.clone(),
1040                    files_done,
1041                    bytes_done: batch_bytes_downloaded,
1042                    current_file: Some(filename),
1043                });
1044            }
1045            P::FileDone {
1046                filename,
1047                file_index,
1048                total_files,
1049                batch_bytes_downloaded,
1050                batch_bytes_total,
1051                ..
1052            } => {
1053                let _ = batch_bytes_total;
1054                queue
1055                    .with_active(|j| {
1056                        j.files_done = file_index + 1;
1057                        j.files_total = total_files;
1058                        j.bytes_done = batch_bytes_downloaded;
1059                    })
1060                    .await;
1061                queue.emit(DownloadEvent::FileDone {
1062                    id: id.clone(),
1063                    filename,
1064                });
1065            }
1066            P::Status { message } => {
1067                // Only surface as a transient info on the active job's current_file
1068                // placeholder. The drawer shows `current_file` literally.
1069                queue
1070                    .with_active(|j| {
1071                        j.current_file = Some(message.clone());
1072                    })
1073                    .await;
1074            }
1075        }
1076    }
1077}
1078
1079fn now_ms() -> i64 {
1080    use std::time::{SystemTime, UNIX_EPOCH};
1081    SystemTime::now()
1082        .duration_since(UNIX_EPOCH)
1083        .map(|d| d.as_millis() as i64)
1084        .unwrap_or(0)
1085}
1086
1087#[cfg(test)]
1088#[path = "downloads_test.rs"]
1089mod tests;