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