Skip to main content

mold_server/
state.rs

1use mold_core::Config;
2use mold_inference::InferenceEngine;
3use std::path::PathBuf;
4use std::sync::atomic::{AtomicUsize, Ordering};
5use std::sync::{Arc, RwLock};
6use std::time::Instant;
7use tokio::sync::Mutex;
8
9use mold_inference::shared_pool::SharedPool;
10
11use crate::downloads::DownloadQueue;
12use crate::events::EventBroadcaster;
13use crate::gpu_pool::GpuPool;
14use crate::job_registry::{JobRegistry, SharedJobRegistry};
15use crate::model_cache::ModelCache;
16use crate::resources::ResourceBroadcaster;
17
18#[derive(Debug, Clone, Default)]
19pub struct EngineSnapshot {
20    /// Currently GPU-loaded model (None if no model on GPU).
21    pub model_name: Option<String>,
22    pub is_loaded: bool,
23    /// All models in the cache (loaded + unloaded), for status display.
24    pub cached_models: Vec<String>,
25}
26
27#[derive(Debug, Clone)]
28pub struct ActiveGenerationSnapshot {
29    pub model: String,
30    pub prompt_sha256: String,
31    pub started_at_unix_ms: u64,
32    pub started_at: Instant,
33}
34
35// ── Generation queue types ──────────────────────────────────────────────────
36
37/// Internal SSE message type used by both the queue worker and SSE streams.
38pub enum SseMessage {
39    Progress(mold_core::SseProgressEvent),
40    Complete(Box<mold_core::SseCompleteEvent>),
41    UpscaleComplete(mold_core::SseUpscaleCompleteEvent),
42    Error(mold_core::SseErrorEvent),
43}
44
45/// Media bytes included in an SSE completion. Mobile clients request
46/// metadata-only completions and load the saved file with Range support.
47#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
48pub enum SseCompletionPayload {
49    #[default]
50    Full,
51    MetadataOnly,
52}
53
54/// A generation job submitted to the queue worker.
55pub struct GenerationJob {
56    /// Server-assigned UUIDv4. Echoed back to the client in the initial
57    /// `SseProgressEvent::Queued` event so the SPA can later reconcile
58    /// a persisted "running" card against `GET /api/queue` — anything
59    /// the registry doesn't know about is a zombie left over from a
60    /// dropped SSE stream and gets dead-lettered client-side. Always
61    /// non-empty for jobs created through the public API; tests that
62    /// construct `GenerationJob` directly may leave it empty.
63    pub id: String,
64    pub request: mold_core::GenerateRequest,
65    pub completion_payload: SseCompletionPayload,
66    /// Channel to send SSE progress/complete/error events (None for non-streaming).
67    pub progress_tx: Option<tokio::sync::mpsc::UnboundedSender<SseMessage>>,
68    /// Oneshot to return the final result for non-streaming callers.
69    pub result_tx: tokio::sync::oneshot::Sender<Result<GenerationJobResult, String>>,
70    /// Pre-resolved output directory for server-side image saving.
71    pub output_dir: Option<PathBuf>,
72}
73
74pub struct GenerationJobResult {
75    pub response: mold_core::GenerateResponse,
76    pub image: mold_core::ImageData,
77}
78
79/// Handle for submitting jobs to the generation queue.
80#[derive(Clone)]
81pub struct QueueHandle {
82    job_tx: tokio::sync::mpsc::Sender<GenerationJob>,
83    pending_count: Arc<AtomicUsize>,
84}
85
86/// Reason a `QueueHandle::submit` attempt failed.
87#[derive(Debug)]
88pub enum SubmitError {
89    /// Queue is at capacity — caller should return 503 with `Retry-After`.
90    Full { pending: usize, capacity: usize },
91    /// Receiving end is gone (server shutting down).
92    Shutdown,
93}
94
95impl QueueHandle {
96    pub fn new(job_tx: tokio::sync::mpsc::Sender<GenerationJob>) -> Self {
97        Self {
98            job_tx,
99            pending_count: Arc::new(AtomicUsize::new(0)),
100        }
101    }
102
103    /// Submit a generation job.
104    ///
105    /// Atomically reserves a slot against `capacity` using fetch_add, so a
106    /// burst of concurrent callers cannot all slip past a separate pending()
107    /// pre-check (TOCTOU).  Returns the queue position on success.
108    pub async fn submit(&self, job: GenerationJob, capacity: usize) -> Result<usize, SubmitError> {
109        let prev = self.pending_count.fetch_add(1, Ordering::SeqCst);
110        if prev >= capacity {
111            self.pending_count.fetch_sub(1, Ordering::SeqCst);
112            return Err(SubmitError::Full {
113                pending: prev,
114                capacity,
115            });
116        }
117        if self.job_tx.send(job).await.is_err() {
118            self.pending_count.fetch_sub(1, Ordering::SeqCst);
119            return Err(SubmitError::Shutdown);
120        }
121        #[cfg(feature = "metrics")]
122        {
123            crate::metrics::record_queue_submit();
124            crate::metrics::record_queue_depth(self.pending_count.load(Ordering::SeqCst));
125        }
126        Ok(prev)
127    }
128
129    pub fn decrement(&self) {
130        self.pending_count.fetch_sub(1, Ordering::SeqCst);
131    }
132
133    pub fn pending(&self) -> usize {
134        self.pending_count.load(Ordering::SeqCst)
135    }
136}
137
138// ── AppState ────────────────────────────────────────────────────────────────
139
140#[derive(Clone)]
141pub struct AppState {
142    /// Stable UUID identifying this server installation. `run_server`
143    /// overwrites the constructor's ephemeral default with the persisted id
144    /// from `crate::instance::resolve_instance_id` before the router (and the
145    /// mDNS TXT records) are built.
146    pub instance_id: Arc<String>,
147    // ── Multi-GPU fields ────────────────────────────────────────────────────
148    /// GPU worker pool for multi-GPU dispatch.
149    pub gpu_pool: Arc<GpuPool>,
150    /// Maximum queue capacity (for status reporting and 503 responses).
151    pub queue_capacity: usize,
152
153    // ── Legacy single-GPU fields (retained during migration) ────────────────
154    pub model_cache: Arc<Mutex<ModelCache>>,
155    /// Uses std::sync::RwLock (not tokio) because it's only accessed from
156    /// synchronous contexts (inside spawn_blocking closures and brief reads).
157    /// Must never be held across an .await point.
158    pub active_generation: Arc<RwLock<Option<ActiveGenerationSnapshot>>>,
159    pub config: Arc<tokio::sync::RwLock<Config>>,
160    pub start_time: Instant,
161    /// Guards concurrent model loads and hot-swaps.
162    pub model_load_lock: Arc<Mutex<()>>,
163    /// Guards concurrent pulls — only one download at a time.
164    pub pull_lock: Arc<Mutex<()>>,
165    /// Generation request queue.
166    pub queue: QueueHandle,
167    /// Authoritative registry of in-flight jobs (queued + running). Powers
168    /// `GET /api/queue` so the SPA can reconcile persisted "running" cards
169    /// against server reality and dead-letter zombies whose SSE stream
170    /// silently dropped.
171    pub job_registry: SharedJobRegistry,
172    /// Dispatch pause gate toggled by `POST /api/queue/pause` /
173    /// `POST /api/queue/resume`. The dispatch loops park on this at the top of
174    /// each iteration; a job already running on a worker finishes untouched.
175    pub queue_pause: Arc<crate::queue::QueuePause>,
176    /// Shared tokenizer pool for cross-engine caching.
177    pub shared_pool: Arc<std::sync::Mutex<SharedPool>>,
178    /// Shutdown trigger for graceful shutdown via `/api/shutdown` endpoint.
179    pub shutdown_tx: Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
180    /// Cached upscaler engine to avoid recreating per request. Small models (2-64MB), single slot.
181    pub upscaler_cache: Arc<std::sync::Mutex<Option<Box<dyn mold_inference::UpscaleEngine>>>>,
182    /// SQLite-backed gallery metadata store. `None` when MOLD_DB_DISABLE=1 or
183    /// when MOLD_HOME could not be resolved — callers must fall back to the
184    /// filesystem walk in `routes::scan_gallery_dir`.
185    pub metadata_db: Arc<Option<mold_db::MetadataDb>>,
186    /// Durable chain-job runner handle. `None` when DB-backed chain jobs are
187    /// unavailable; chain-job API handlers return 503 in that state.
188    pub chain_jobs: Option<Arc<crate::chain_job_runner::ChainJobRunnerHandle>>,
189    // ── Downloads UI (Agent A) ──────────────────────────────────────────────
190    /// Bounded-parallel download queue.
191    pub downloads: Arc<DownloadQueue>,
192    /// Always-on resource telemetry (Agent B).
193    pub resources: Arc<ResourceBroadcaster>,
194    /// Server-wide lifecycle broadcast backing `GET /api/events` — job
195    /// queued/started/ended (mirrored by `job_registry`) plus gallery
196    /// added/removed. One SSE connection observes the whole server.
197    pub events: Arc<EventBroadcaster>,
198    // ── Catalog (live HF + Civitai) ─────────────────────────────────────────
199    /// In-process TTL cache backing `/api/catalog/search`.
200    pub catalog_live_cache: mold_catalog::live::LiveCache,
201    /// Upstream base URL for live Civitai search. Production runs with
202    /// the public host; tests override via `with_civitai_base`.
203    pub catalog_live_civitai_base: Arc<String>,
204    /// Cache of pure synthesis intents for installed catalog (`cv:*`/`hf:*`)
205    /// IDs, keyed by ID. Resolution into a `ModelConfig` is deferred to
206    /// engine-load time so disk-state races (file present vs not) can't
207    /// seal a wrong config; see `model_manager::resolve_intent_to_paths`.
208    pub catalog_intents: Arc<
209        tokio::sync::RwLock<
210            std::collections::HashMap<String, mold_catalog::synthesis::CatalogModelIntent>,
211        >,
212    >,
213    /// Cached disk snapshot for the models dir, served by `/api/status`.
214    /// Refreshed off the request path — statvfs can hang outright on a
215    /// wedged FUSE mount, so the handler must never wait on a disk scan.
216    pub models_disk_cache: Arc<ModelsDiskCache>,
217}
218
219/// TTL for the cached models-disk snapshot. Matches the ~10s status poll
220/// cadence: pollers see numbers at most one refresh stale.
221pub const MODELS_DISK_TTL: std::time::Duration = std::time::Duration::from_secs(15);
222
223/// Last-good disk stats for the models dir plus a single-flight refresh
224/// claim. `/api/status` reads the snapshot synchronously and — when it has
225/// gone stale — at most one request wins the claim and kicks a background
226/// `spawn_blocking` refresh; everyone else keeps serving the last-good value.
227/// A hung statvfs therefore costs one leaked blocking-pool thread and a stale
228/// stat, never a parked runtime worker.
229#[derive(Default)]
230pub struct ModelsDiskCache {
231    inner: std::sync::Mutex<ModelsDiskSnapshot>,
232    refreshing: std::sync::atomic::AtomicBool,
233}
234
235#[derive(Default)]
236struct ModelsDiskSnapshot {
237    usage: Option<mold_core::DiskUsage>,
238    fetched_at: Option<Instant>,
239}
240
241impl ModelsDiskCache {
242    /// Current snapshot plus whether the caller won the refresh claim.
243    /// Returns `true` at most once per stale period — the winner must call
244    /// [`ModelsDiskCache::store`] (or [`ModelsDiskCache::release`]) to let
245    /// the next refresh happen.
246    pub fn read(&self) -> (Option<mold_core::DiskUsage>, bool) {
247        self.read_at(Instant::now())
248    }
249
250    fn read_at(&self, now: Instant) -> (Option<mold_core::DiskUsage>, bool) {
251        let (usage, stale) = {
252            let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
253            let stale = inner
254                .fetched_at
255                .is_none_or(|at| now.duration_since(at) >= MODELS_DISK_TTL);
256            (inner.usage.clone(), stale)
257        };
258        if !stale {
259            return (usage, false);
260        }
261        let won_claim = !self
262            .refreshing
263            .swap(true, std::sync::atomic::Ordering::AcqRel);
264        (usage, won_claim)
265    }
266
267    /// Publish a fresh snapshot and release the refresh claim.
268    pub fn store(&self, usage: Option<mold_core::DiskUsage>) {
269        {
270            let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
271            inner.usage = usage;
272            inner.fetched_at = Some(Instant::now());
273        }
274        self.release();
275    }
276
277    /// Release the refresh claim without publishing (refresh task failed).
278    pub fn release(&self) {
279        self.refreshing
280            .store(false, std::sync::atomic::Ordering::Release);
281    }
282}
283
284/// Default TTL for the live-search cache. Five minutes is long enough
285/// to absorb SPA re-mounts and quick paging without serving stale
286/// rankings.
287const CATALOG_LIVE_CACHE_TTL_SECS: u64 = 300;
288/// Cap on cached query keys. The working set per user is small —
289/// caps too high just retain stale rows past their TTL.
290const CATALOG_LIVE_CACHE_MAX_KEYS: usize = 256;
291/// Production Civitai endpoint. Tests inject a wiremock URI via
292/// [`AppState::with_civitai_base`].
293pub const CATALOG_LIVE_CIVITAI_BASE: &str = "https://civitai.com";
294
295fn default_live_cache() -> mold_catalog::live::LiveCache {
296    mold_catalog::live::LiveCache::new(
297        std::time::Duration::from_secs(CATALOG_LIVE_CACHE_TTL_SECS),
298        CATALOG_LIVE_CACHE_MAX_KEYS,
299    )
300}
301
302/// Default maximum number of cached models (GPU-resident + parked engine structs).
303pub const DEFAULT_MAX_CACHED_MODELS: usize = 3;
304/// Lower / upper bounds applied to env-overridden cache caps. Below 1 the
305/// cache can't hold the active engine; above 16 the OOM risk dwarfs the
306/// hit-rate gains for a typical local server.
307const MAX_CACHED_MODELS_LOWER: usize = 1;
308const MAX_CACHED_MODELS_UPPER: usize = 16;
309/// Env var that overrides `DEFAULT_MAX_CACHED_MODELS` at runtime.
310pub const MAX_CACHED_MODELS_ENV: &str = "MOLD_MAX_CACHED_MODELS";
311
312/// Default idle-TTL for parked cache entries — 30 minutes. Tuned for a
313/// local-first workflow: long enough that a user toggling between two
314/// models inside a session never pays a reload, short enough that
315/// background memory pressure doesn't accumulate overnight.
316pub const DEFAULT_CACHE_IDLE_TTL_SECS: u64 = 1800;
317const CACHE_IDLE_TTL_LOWER_SECS: u64 = 60;
318const CACHE_IDLE_TTL_UPPER_SECS: u64 = 86_400;
319/// Env var that overrides `DEFAULT_CACHE_IDLE_TTL_SECS`.
320pub const CACHE_IDLE_TTL_ENV: &str = "MOLD_CACHE_IDLE_TTL_SECS";
321
322/// Resolve the cache idle-TTL from env, falling back to the default.
323/// Out-of-range or unparseable values log a warning and use the default.
324pub fn resolve_cache_idle_ttl_secs() -> u64 {
325    match std::env::var(CACHE_IDLE_TTL_ENV) {
326        Ok(raw) => match raw.trim().parse::<u64>() {
327            Ok(n) if (CACHE_IDLE_TTL_LOWER_SECS..=CACHE_IDLE_TTL_UPPER_SECS).contains(&n) => n,
328            Ok(n) => {
329                tracing::warn!(
330                    env = CACHE_IDLE_TTL_ENV,
331                    value = n,
332                    lower = CACHE_IDLE_TTL_LOWER_SECS,
333                    upper = CACHE_IDLE_TTL_UPPER_SECS,
334                    "ignoring out-of-range cache idle-TTL; using default"
335                );
336                DEFAULT_CACHE_IDLE_TTL_SECS
337            }
338            Err(e) => {
339                tracing::warn!(
340                    env = CACHE_IDLE_TTL_ENV,
341                    raw = %raw,
342                    error = %e,
343                    "ignoring unparseable cache idle-TTL; using default"
344                );
345                DEFAULT_CACHE_IDLE_TTL_SECS
346            }
347        },
348        Err(_) => DEFAULT_CACHE_IDLE_TTL_SECS,
349    }
350}
351
352/// Resolve the model-cache capacity from env, falling back to the default.
353/// Out-of-range or unparseable values log a warning and use the default so
354/// a typo in the env never silently shrinks the cache to an unusable size.
355pub fn resolve_max_cached_models() -> usize {
356    match std::env::var(MAX_CACHED_MODELS_ENV) {
357        Ok(raw) => match raw.trim().parse::<usize>() {
358            Ok(n) if (MAX_CACHED_MODELS_LOWER..=MAX_CACHED_MODELS_UPPER).contains(&n) => n,
359            Ok(n) => {
360                tracing::warn!(
361                    env = MAX_CACHED_MODELS_ENV,
362                    value = n,
363                    lower = MAX_CACHED_MODELS_LOWER,
364                    upper = MAX_CACHED_MODELS_UPPER,
365                    "ignoring out-of-range cache cap; using default"
366                );
367                DEFAULT_MAX_CACHED_MODELS
368            }
369            Err(e) => {
370                tracing::warn!(
371                    env = MAX_CACHED_MODELS_ENV,
372                    raw = %raw,
373                    error = %e,
374                    "ignoring unparseable cache cap; using default"
375                );
376                DEFAULT_MAX_CACHED_MODELS
377            }
378        },
379        Err(_) => DEFAULT_MAX_CACHED_MODELS,
380    }
381}
382
383impl AppState {
384    /// Create state with a pre-loaded engine (server starts with a configured model).
385    pub fn new(
386        engine: Box<dyn InferenceEngine>,
387        config: Config,
388        queue: QueueHandle,
389        gpu_pool: Arc<GpuPool>,
390        queue_capacity: usize,
391    ) -> Self {
392        let mut cache = ModelCache::new(resolve_max_cached_models());
393        cache.insert(engine, 0);
394        let events = EventBroadcaster::new();
395        Self {
396            instance_id: Arc::new(uuid::Uuid::new_v4().to_string()),
397            gpu_pool,
398            queue_capacity,
399            model_cache: Arc::new(Mutex::new(cache)),
400            active_generation: Arc::new(RwLock::new(None)),
401            config: Arc::new(tokio::sync::RwLock::new(config)),
402            start_time: Instant::now(),
403            model_load_lock: Arc::new(Mutex::new(())),
404            pull_lock: Arc::new(Mutex::new(())),
405            queue,
406            job_registry: JobRegistry::with_events(events.clone()),
407            queue_pause: crate::queue::QueuePause::new(),
408            shared_pool: Arc::new(std::sync::Mutex::new(SharedPool::new())),
409            shutdown_tx: Arc::new(tokio::sync::Mutex::new(None)),
410            upscaler_cache: Arc::new(std::sync::Mutex::new(None)),
411            metadata_db: Arc::new(None),
412            chain_jobs: None,
413            downloads: DownloadQueue::new(),
414            resources: ResourceBroadcaster::new(),
415            events,
416            catalog_live_cache: default_live_cache(),
417            catalog_live_civitai_base: Arc::new(CATALOG_LIVE_CIVITAI_BASE.to_string()),
418            catalog_intents: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
419            models_disk_cache: Arc::new(ModelsDiskCache::default()),
420        }
421    }
422
423    /// Create state with no engine (zero-config startup, models pulled on demand).
424    pub fn empty(
425        config: Config,
426        queue: QueueHandle,
427        gpu_pool: Arc<GpuPool>,
428        queue_capacity: usize,
429    ) -> Self {
430        let events = EventBroadcaster::new();
431        Self {
432            instance_id: Arc::new(uuid::Uuid::new_v4().to_string()),
433            gpu_pool,
434            queue_capacity,
435            model_cache: Arc::new(Mutex::new(ModelCache::new(resolve_max_cached_models()))),
436            active_generation: Arc::new(RwLock::new(None)),
437            config: Arc::new(tokio::sync::RwLock::new(config)),
438            start_time: Instant::now(),
439            model_load_lock: Arc::new(Mutex::new(())),
440            pull_lock: Arc::new(Mutex::new(())),
441            queue,
442            job_registry: JobRegistry::with_events(events.clone()),
443            queue_pause: crate::queue::QueuePause::new(),
444            shared_pool: Arc::new(std::sync::Mutex::new(SharedPool::new())),
445            shutdown_tx: Arc::new(tokio::sync::Mutex::new(None)),
446            upscaler_cache: Arc::new(std::sync::Mutex::new(None)),
447            metadata_db: Arc::new(None),
448            chain_jobs: None,
449            downloads: DownloadQueue::new(),
450            resources: ResourceBroadcaster::new(),
451            events,
452            catalog_live_cache: default_live_cache(),
453            catalog_live_civitai_base: Arc::new(CATALOG_LIVE_CIVITAI_BASE.to_string()),
454            catalog_intents: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
455            models_disk_cache: Arc::new(ModelsDiskCache::default()),
456        }
457    }
458
459    /// Create an empty GpuPool for testing (no GPU workers).
460    #[cfg(test)]
461    pub(crate) fn empty_gpu_pool() -> Arc<GpuPool> {
462        Arc::new(GpuPool {
463            workers: Vec::new(),
464        })
465    }
466
467    /// Alias for `empty_gpu_pool` — exposed for tests in sibling modules
468    /// (routes_test.rs, downloads_test.rs) that live in the crate but not
469    /// in the same file.
470    #[cfg(test)]
471    pub(crate) fn empty_gpu_pool_for_test() -> Arc<GpuPool> {
472        Self::empty_gpu_pool()
473    }
474
475    #[cfg(test)]
476    pub fn with_engine(engine: impl InferenceEngine + 'static) -> Self {
477        let (tx, _rx) = tokio::sync::mpsc::channel(16);
478        let queue = QueueHandle::new(tx);
479        let mut cache = ModelCache::new(resolve_max_cached_models());
480        cache.insert(Box::new(engine), 0);
481        let events = EventBroadcaster::new();
482        Self {
483            instance_id: Arc::new(uuid::Uuid::new_v4().to_string()),
484            gpu_pool: Self::empty_gpu_pool(),
485            queue_capacity: 200,
486            model_cache: Arc::new(Mutex::new(cache)),
487            active_generation: Arc::new(RwLock::new(None)),
488            config: Arc::new(tokio::sync::RwLock::new(Config::default())),
489            start_time: Instant::now(),
490            model_load_lock: Arc::new(Mutex::new(())),
491            pull_lock: Arc::new(Mutex::new(())),
492            queue,
493            job_registry: JobRegistry::with_events(events.clone()),
494            queue_pause: crate::queue::QueuePause::new(),
495            shared_pool: Arc::new(std::sync::Mutex::new(SharedPool::new())),
496            shutdown_tx: Arc::new(tokio::sync::Mutex::new(None)),
497            upscaler_cache: Arc::new(std::sync::Mutex::new(None)),
498            metadata_db: Arc::new(None),
499            chain_jobs: None,
500            downloads: DownloadQueue::new(),
501            resources: ResourceBroadcaster::new(),
502            events,
503            catalog_live_cache: default_live_cache(),
504            catalog_live_civitai_base: Arc::new(CATALOG_LIVE_CIVITAI_BASE.to_string()),
505            catalog_intents: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
506            models_disk_cache: Arc::new(ModelsDiskCache::default()),
507        }
508    }
509
510    /// Create state with a queue whose receiver is returned for testing.
511    #[cfg(test)]
512    pub fn with_engine_and_queue(
513        engine: impl InferenceEngine + 'static,
514    ) -> (Self, tokio::sync::mpsc::Receiver<GenerationJob>) {
515        let (tx, rx) = tokio::sync::mpsc::channel(16);
516        let queue = QueueHandle::new(tx);
517        let mut cache = ModelCache::new(resolve_max_cached_models());
518        cache.insert(Box::new(engine), 0);
519        let events = EventBroadcaster::new();
520        let state = Self {
521            instance_id: Arc::new(uuid::Uuid::new_v4().to_string()),
522            gpu_pool: Self::empty_gpu_pool(),
523            queue_capacity: 200,
524            model_cache: Arc::new(Mutex::new(cache)),
525            active_generation: Arc::new(RwLock::new(None)),
526            config: Arc::new(tokio::sync::RwLock::new(Config::default())),
527            start_time: Instant::now(),
528            model_load_lock: Arc::new(Mutex::new(())),
529            pull_lock: Arc::new(Mutex::new(())),
530            queue,
531            job_registry: JobRegistry::with_events(events.clone()),
532            queue_pause: crate::queue::QueuePause::new(),
533            shared_pool: Arc::new(std::sync::Mutex::new(SharedPool::new())),
534            shutdown_tx: Arc::new(tokio::sync::Mutex::new(None)),
535            upscaler_cache: Arc::new(std::sync::Mutex::new(None)),
536            metadata_db: Arc::new(None),
537            chain_jobs: None,
538            downloads: DownloadQueue::new(),
539            resources: ResourceBroadcaster::new(),
540            events,
541            catalog_live_cache: default_live_cache(),
542            catalog_live_civitai_base: Arc::new(CATALOG_LIVE_CIVITAI_BASE.to_string()),
543            catalog_intents: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
544            models_disk_cache: Arc::new(ModelsDiskCache::default()),
545        };
546        (state, rx)
547    }
548
549    /// Construct an empty AppState for integration tests. Catalog
550    /// surfaces hit live HF/Civitai (test callers point those at a
551    /// wiremock instance via `with_civitai_base`).
552    pub fn for_tests() -> Self {
553        let (tx, _rx) = tokio::sync::mpsc::channel(16);
554        let queue = QueueHandle::new(tx);
555        let events = EventBroadcaster::new();
556        Self {
557            instance_id: Arc::new(uuid::Uuid::new_v4().to_string()),
558            gpu_pool: Arc::new(GpuPool {
559                workers: Vec::new(),
560            }),
561            queue_capacity: 200,
562            model_cache: Arc::new(Mutex::new(ModelCache::new(resolve_max_cached_models()))),
563            active_generation: Arc::new(RwLock::new(None)),
564            config: Arc::new(tokio::sync::RwLock::new(Config::default())),
565            start_time: Instant::now(),
566            model_load_lock: Arc::new(Mutex::new(())),
567            pull_lock: Arc::new(Mutex::new(())),
568            queue,
569            job_registry: JobRegistry::with_events(events.clone()),
570            queue_pause: crate::queue::QueuePause::new(),
571            shared_pool: Arc::new(std::sync::Mutex::new(SharedPool::new())),
572            shutdown_tx: Arc::new(tokio::sync::Mutex::new(None)),
573            upscaler_cache: Arc::new(std::sync::Mutex::new(None)),
574            metadata_db: Arc::new(None),
575            chain_jobs: None,
576            downloads: DownloadQueue::new(),
577            resources: ResourceBroadcaster::new(),
578            events,
579            catalog_live_cache: default_live_cache(),
580            catalog_live_civitai_base: Arc::new(CATALOG_LIVE_CIVITAI_BASE.to_string()),
581            catalog_intents: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
582            models_disk_cache: Arc::new(ModelsDiskCache::default()),
583        }
584    }
585
586    /// Override the live-search Civitai base URL on an existing state.
587    /// Tests point this at a wiremock instance; production never calls
588    /// it (the `new` / `empty` constructors set the public host).
589    pub fn with_civitai_base(mut self, base: impl Into<String>) -> Self {
590        self.catalog_live_civitai_base = Arc::new(base.into());
591        self
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn models_disk_cache_serves_last_good_and_single_flights_refreshes() {
601        let cache = ModelsDiskCache::default();
602
603        // Fresh cache: nothing to serve, first reader wins the refresh claim…
604        let (usage, refresh) = cache.read();
605        assert_eq!(usage, None);
606        assert!(refresh, "first reader must win the refresh claim");
607        // …and nobody else does until the refresh completes (single-flight —
608        // a hung statvfs must not leak one blocking thread per status poll).
609        let (usage, refresh) = cache.read();
610        assert_eq!(usage, None);
611        assert!(!refresh, "claim must not be handed out twice");
612
613        // A completed refresh publishes and is served without re-claiming.
614        let stats = mold_core::DiskUsage {
615            total_bytes: 500,
616            free_bytes: 50,
617        };
618        cache.store(Some(stats.clone()));
619        let (usage, refresh) = cache.read();
620        assert_eq!(usage, Some(stats.clone()));
621        assert!(!refresh, "fresh snapshot must not trigger a refresh");
622
623        // Past the TTL the stale value keeps being served while exactly one
624        // reader wins the next refresh claim.
625        let later = Instant::now() + MODELS_DISK_TTL;
626        let (usage, refresh) = cache.read_at(later);
627        assert_eq!(usage, Some(stats.clone()), "stale value still served");
628        assert!(refresh, "stale snapshot must trigger one refresh");
629        let (usage, refresh) = cache.read_at(later);
630        assert_eq!(usage, Some(stats));
631        assert!(!refresh);
632    }
633
634    #[test]
635    fn models_disk_cache_release_reopens_the_claim_without_publishing() {
636        let cache = ModelsDiskCache::default();
637        let (_, refresh) = cache.read();
638        assert!(refresh);
639        // A refresh task that failed must hand the claim back, or the cache
640        // would never refresh again for the life of the process.
641        cache.release();
642        let (usage, refresh) = cache.read();
643        assert_eq!(usage, None, "release publishes nothing");
644        assert!(refresh, "claim must be available again after release");
645    }
646
647    #[test]
648    fn engine_snapshot_default_is_unloaded() {
649        let snap = EngineSnapshot::default();
650        assert!(snap.model_name.is_none());
651        assert!(!snap.is_loaded);
652    }
653
654    #[test]
655    fn active_generation_snapshot_stores_fields() {
656        let snap = ActiveGenerationSnapshot {
657            model: "flux-dev:q8".to_string(),
658            prompt_sha256: "abc123".to_string(),
659            started_at_unix_ms: 1700000000000,
660            started_at: std::time::Instant::now(),
661        };
662        assert_eq!(snap.model, "flux-dev:q8");
663        assert_eq!(snap.prompt_sha256, "abc123");
664        assert_eq!(snap.started_at_unix_ms, 1700000000000);
665    }
666
667    #[test]
668    fn queue_handle_pending_starts_at_zero() {
669        let (tx, _rx) = tokio::sync::mpsc::channel::<GenerationJob>(16);
670        let handle = QueueHandle::new(tx);
671        assert_eq!(handle.pending(), 0);
672    }
673
674    #[test]
675    fn upscaler_cache_starts_empty() {
676        let config = mold_core::Config::default();
677        let state = AppState::empty(
678            config,
679            QueueHandle::new(tokio::sync::mpsc::channel(1).0),
680            AppState::empty_gpu_pool(),
681            200,
682        );
683        let cache = state.upscaler_cache.lock().unwrap();
684        assert!(cache.is_none());
685    }
686
687    #[test]
688    fn upscaler_cache_cleared_by_setting_none() {
689        let config = mold_core::Config::default();
690        let state = AppState::empty(
691            config,
692            QueueHandle::new(tokio::sync::mpsc::channel(1).0),
693            AppState::empty_gpu_pool(),
694            200,
695        );
696        {
697            let mut cache = state.upscaler_cache.lock().unwrap();
698            *cache = None;
699        }
700        let cache = state.upscaler_cache.lock().unwrap();
701        assert!(cache.is_none());
702    }
703
704    #[test]
705    fn app_state_exposes_resources_broadcaster() {
706        let config = mold_core::Config::default();
707        let state = AppState::empty(
708            config,
709            QueueHandle::new(tokio::sync::mpsc::channel(1).0),
710            AppState::empty_gpu_pool(),
711            200,
712        );
713        // The broadcaster must exist and return None before any aggregator tick.
714        assert!(state.resources.latest().is_none());
715        // Subscribing must succeed (no panics).
716        let _rx = state.resources.subscribe();
717    }
718
719    /// Serializes every test that touches a process-wide env var via
720    /// `std::env::set_var` — mutating env is global state, so without this
721    /// guard parallel tests would race on the env table.
722    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
723
724    /// Set `name` to `value` (or remove it when `value` is `None`), invoke
725    /// `f`, then restore the original value. Lock-serialized so concurrent
726    /// tests don't race on the env table.
727    fn with_env<R>(name: &str, value: Option<&str>, f: impl FnOnce() -> R) -> R {
728        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
729        let prev = std::env::var(name).ok();
730        match value {
731            Some(v) => std::env::set_var(name, v),
732            None => std::env::remove_var(name),
733        }
734        let out = f();
735        match prev {
736            Some(v) => std::env::set_var(name, v),
737            None => std::env::remove_var(name),
738        }
739        out
740    }
741
742    #[test]
743    fn resolve_max_cached_uses_default_when_env_missing() {
744        let n = with_env(MAX_CACHED_MODELS_ENV, None, resolve_max_cached_models);
745        assert_eq!(n, DEFAULT_MAX_CACHED_MODELS);
746    }
747
748    #[test]
749    fn resolve_max_cached_honors_env_within_range() {
750        let n = with_env(MAX_CACHED_MODELS_ENV, Some("8"), resolve_max_cached_models);
751        assert_eq!(n, 8);
752    }
753
754    #[test]
755    fn resolve_max_cached_clamps_zero_back_to_default() {
756        let n = with_env(MAX_CACHED_MODELS_ENV, Some("0"), resolve_max_cached_models);
757        assert_eq!(n, DEFAULT_MAX_CACHED_MODELS);
758    }
759
760    #[test]
761    fn resolve_max_cached_clamps_overflow_back_to_default() {
762        let n = with_env(
763            MAX_CACHED_MODELS_ENV,
764            Some("999"),
765            resolve_max_cached_models,
766        );
767        assert_eq!(n, DEFAULT_MAX_CACHED_MODELS);
768    }
769
770    #[test]
771    fn resolve_max_cached_falls_back_when_env_unparseable() {
772        let n = with_env(
773            MAX_CACHED_MODELS_ENV,
774            Some("not-a-number"),
775            resolve_max_cached_models,
776        );
777        assert_eq!(n, DEFAULT_MAX_CACHED_MODELS);
778    }
779
780    #[test]
781    fn resolve_cache_idle_ttl_uses_default_when_env_missing() {
782        let n = with_env(CACHE_IDLE_TTL_ENV, None, resolve_cache_idle_ttl_secs);
783        assert_eq!(n, DEFAULT_CACHE_IDLE_TTL_SECS);
784    }
785
786    #[test]
787    fn resolve_cache_idle_ttl_honors_env_within_range() {
788        // 600s is comfortably inside [60, 86_400] — the resolver must echo it.
789        let n = with_env(CACHE_IDLE_TTL_ENV, Some("600"), resolve_cache_idle_ttl_secs);
790        assert_eq!(n, 600);
791    }
792
793    #[test]
794    fn resolve_cache_idle_ttl_clamps_zero_back_to_default() {
795        // 0 is below the 60s lower bound; falls back to the default with a warn log.
796        let n = with_env(CACHE_IDLE_TTL_ENV, Some("0"), resolve_cache_idle_ttl_secs);
797        assert_eq!(n, DEFAULT_CACHE_IDLE_TTL_SECS);
798    }
799
800    #[test]
801    fn resolve_cache_idle_ttl_clamps_overflow_back_to_default() {
802        // 100_000 is above the 86_400s upper bound; falls back to the default.
803        let n = with_env(
804            CACHE_IDLE_TTL_ENV,
805            Some("100000"),
806            resolve_cache_idle_ttl_secs,
807        );
808        assert_eq!(n, DEFAULT_CACHE_IDLE_TTL_SECS);
809    }
810
811    #[test]
812    fn resolve_cache_idle_ttl_falls_back_when_env_unparseable() {
813        let n = with_env(
814            CACHE_IDLE_TTL_ENV,
815            Some("not-a-number"),
816            resolve_cache_idle_ttl_secs,
817        );
818        assert_eq!(n, DEFAULT_CACHE_IDLE_TTL_SECS);
819    }
820}