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