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::gpu_pool::GpuPool;
13use crate::job_registry::{JobRegistry, SharedJobRegistry};
14use crate::model_cache::ModelCache;
15use crate::resources::ResourceBroadcaster;
16
17#[derive(Debug, Clone, Default)]
18pub struct EngineSnapshot {
19    /// Currently GPU-loaded model (None if no model on GPU).
20    pub model_name: Option<String>,
21    pub is_loaded: bool,
22    /// All models in the cache (loaded + unloaded), for status display.
23    pub cached_models: Vec<String>,
24}
25
26#[derive(Debug, Clone)]
27pub struct ActiveGenerationSnapshot {
28    pub model: String,
29    pub prompt_sha256: String,
30    pub started_at_unix_ms: u64,
31    pub started_at: Instant,
32}
33
34// ── Generation queue types ──────────────────────────────────────────────────
35
36/// Internal SSE message type used by both the queue worker and SSE streams.
37pub enum SseMessage {
38    Progress(mold_core::SseProgressEvent),
39    Complete(mold_core::SseCompleteEvent),
40    UpscaleComplete(mold_core::SseUpscaleCompleteEvent),
41    Error(mold_core::SseErrorEvent),
42}
43
44/// A generation job submitted to the queue worker.
45pub struct GenerationJob {
46    /// Server-assigned UUIDv4. Echoed back to the client in the initial
47    /// `SseProgressEvent::Queued` event so the SPA can later reconcile
48    /// a persisted "running" card against `GET /api/queue` — anything
49    /// the registry doesn't know about is a zombie left over from a
50    /// dropped SSE stream and gets dead-lettered client-side. Always
51    /// non-empty for jobs created through the public API; tests that
52    /// construct `GenerationJob` directly may leave it empty.
53    pub id: String,
54    pub request: mold_core::GenerateRequest,
55    /// Channel to send SSE progress/complete/error events (None for non-streaming).
56    pub progress_tx: Option<tokio::sync::mpsc::UnboundedSender<SseMessage>>,
57    /// Oneshot to return the final result for non-streaming callers.
58    pub result_tx: tokio::sync::oneshot::Sender<Result<GenerationJobResult, String>>,
59    /// Pre-resolved output directory for server-side image saving.
60    pub output_dir: Option<PathBuf>,
61}
62
63pub struct GenerationJobResult {
64    pub response: mold_core::GenerateResponse,
65    pub image: mold_core::ImageData,
66}
67
68/// Handle for submitting jobs to the generation queue.
69#[derive(Clone)]
70pub struct QueueHandle {
71    job_tx: tokio::sync::mpsc::Sender<GenerationJob>,
72    pending_count: Arc<AtomicUsize>,
73}
74
75/// Reason a `QueueHandle::submit` attempt failed.
76#[derive(Debug)]
77pub enum SubmitError {
78    /// Queue is at capacity — caller should return 503 with `Retry-After`.
79    Full { pending: usize, capacity: usize },
80    /// Receiving end is gone (server shutting down).
81    Shutdown,
82}
83
84impl QueueHandle {
85    pub fn new(job_tx: tokio::sync::mpsc::Sender<GenerationJob>) -> Self {
86        Self {
87            job_tx,
88            pending_count: Arc::new(AtomicUsize::new(0)),
89        }
90    }
91
92    /// Submit a generation job.
93    ///
94    /// Atomically reserves a slot against `capacity` using fetch_add, so a
95    /// burst of concurrent callers cannot all slip past a separate pending()
96    /// pre-check (TOCTOU).  Returns the queue position on success.
97    pub async fn submit(&self, job: GenerationJob, capacity: usize) -> Result<usize, SubmitError> {
98        let prev = self.pending_count.fetch_add(1, Ordering::SeqCst);
99        if prev >= capacity {
100            self.pending_count.fetch_sub(1, Ordering::SeqCst);
101            return Err(SubmitError::Full {
102                pending: prev,
103                capacity,
104            });
105        }
106        if self.job_tx.send(job).await.is_err() {
107            self.pending_count.fetch_sub(1, Ordering::SeqCst);
108            return Err(SubmitError::Shutdown);
109        }
110        #[cfg(feature = "metrics")]
111        {
112            crate::metrics::record_queue_submit();
113            crate::metrics::record_queue_depth(self.pending_count.load(Ordering::SeqCst));
114        }
115        Ok(prev)
116    }
117
118    pub fn decrement(&self) {
119        self.pending_count.fetch_sub(1, Ordering::SeqCst);
120    }
121
122    pub fn pending(&self) -> usize {
123        self.pending_count.load(Ordering::SeqCst)
124    }
125}
126
127// ── AppState ────────────────────────────────────────────────────────────────
128
129#[derive(Clone)]
130pub struct AppState {
131    // ── Multi-GPU fields ────────────────────────────────────────────────────
132    /// GPU worker pool for multi-GPU dispatch.
133    pub gpu_pool: Arc<GpuPool>,
134    /// Maximum queue capacity (for status reporting and 503 responses).
135    pub queue_capacity: usize,
136
137    // ── Legacy single-GPU fields (retained during migration) ────────────────
138    pub model_cache: Arc<Mutex<ModelCache>>,
139    /// Uses std::sync::RwLock (not tokio) because it's only accessed from
140    /// synchronous contexts (inside spawn_blocking closures and brief reads).
141    /// Must never be held across an .await point.
142    pub active_generation: Arc<RwLock<Option<ActiveGenerationSnapshot>>>,
143    pub config: Arc<tokio::sync::RwLock<Config>>,
144    pub start_time: Instant,
145    /// Guards concurrent model loads and hot-swaps.
146    pub model_load_lock: Arc<Mutex<()>>,
147    /// Guards concurrent pulls — only one download at a time.
148    pub pull_lock: Arc<Mutex<()>>,
149    /// Generation request queue.
150    pub queue: QueueHandle,
151    /// Authoritative registry of in-flight jobs (queued + running). Powers
152    /// `GET /api/queue` so the SPA can reconcile persisted "running" cards
153    /// against server reality and dead-letter zombies whose SSE stream
154    /// silently dropped.
155    pub job_registry: SharedJobRegistry,
156    /// Shared tokenizer pool for cross-engine caching.
157    pub shared_pool: Arc<std::sync::Mutex<SharedPool>>,
158    /// Shutdown trigger for graceful shutdown via `/api/shutdown` endpoint.
159    pub shutdown_tx: Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
160    /// Cached upscaler engine to avoid recreating per request. Small models (2-64MB), single slot.
161    pub upscaler_cache: Arc<std::sync::Mutex<Option<Box<dyn mold_inference::UpscaleEngine>>>>,
162    /// SQLite-backed gallery metadata store. `None` when MOLD_DB_DISABLE=1 or
163    /// when MOLD_HOME could not be resolved — callers must fall back to the
164    /// filesystem walk in `routes::scan_gallery_dir`.
165    pub metadata_db: Arc<Option<mold_db::MetadataDb>>,
166    /// Durable chain-job runner handle. `None` when DB-backed chain jobs are
167    /// unavailable; chain-job API handlers return 503 in that state.
168    pub chain_jobs: Option<Arc<crate::chain_job_runner::ChainJobRunnerHandle>>,
169    // ── Downloads UI (Agent A) ──────────────────────────────────────────────
170    /// Bounded-parallel download queue.
171    pub downloads: Arc<DownloadQueue>,
172    /// Always-on resource telemetry (Agent B).
173    pub resources: Arc<ResourceBroadcaster>,
174    // ── Catalog (live HF + Civitai) ─────────────────────────────────────────
175    /// In-process TTL cache backing `/api/catalog/search`.
176    pub catalog_live_cache: mold_catalog::live::LiveCache,
177    /// Upstream base URL for live Civitai search. Production runs with
178    /// the public host; tests override via `with_civitai_base`.
179    pub catalog_live_civitai_base: Arc<String>,
180    /// Cache of pure synthesis intents for installed catalog (`cv:*`/`hf:*`)
181    /// IDs, keyed by ID. Resolution into a `ModelConfig` is deferred to
182    /// engine-load time so disk-state races (file present vs not) can't
183    /// seal a wrong config; see `model_manager::resolve_intent_to_paths`.
184    pub catalog_intents: Arc<
185        tokio::sync::RwLock<
186            std::collections::HashMap<String, mold_catalog::synthesis::CatalogModelIntent>,
187        >,
188    >,
189}
190
191/// Default TTL for the live-search cache. Five minutes is long enough
192/// to absorb SPA re-mounts and quick paging without serving stale
193/// rankings.
194const CATALOG_LIVE_CACHE_TTL_SECS: u64 = 300;
195/// Cap on cached query keys. The working set per user is small —
196/// caps too high just retain stale rows past their TTL.
197const CATALOG_LIVE_CACHE_MAX_KEYS: usize = 256;
198/// Production Civitai endpoint. Tests inject a wiremock URI via
199/// [`AppState::with_civitai_base`].
200pub const CATALOG_LIVE_CIVITAI_BASE: &str = "https://civitai.com";
201
202fn default_live_cache() -> mold_catalog::live::LiveCache {
203    mold_catalog::live::LiveCache::new(
204        std::time::Duration::from_secs(CATALOG_LIVE_CACHE_TTL_SECS),
205        CATALOG_LIVE_CACHE_MAX_KEYS,
206    )
207}
208
209/// Default maximum number of cached models (GPU-resident + parked engine structs).
210pub const DEFAULT_MAX_CACHED_MODELS: usize = 3;
211/// Lower / upper bounds applied to env-overridden cache caps. Below 1 the
212/// cache can't hold the active engine; above 16 the OOM risk dwarfs the
213/// hit-rate gains for a typical local server.
214const MAX_CACHED_MODELS_LOWER: usize = 1;
215const MAX_CACHED_MODELS_UPPER: usize = 16;
216/// Env var that overrides `DEFAULT_MAX_CACHED_MODELS` at runtime.
217pub const MAX_CACHED_MODELS_ENV: &str = "MOLD_MAX_CACHED_MODELS";
218
219/// Default idle-TTL for parked cache entries — 30 minutes. Tuned for a
220/// local-first workflow: long enough that a user toggling between two
221/// models inside a session never pays a reload, short enough that
222/// background memory pressure doesn't accumulate overnight.
223pub const DEFAULT_CACHE_IDLE_TTL_SECS: u64 = 1800;
224const CACHE_IDLE_TTL_LOWER_SECS: u64 = 60;
225const CACHE_IDLE_TTL_UPPER_SECS: u64 = 86_400;
226/// Env var that overrides `DEFAULT_CACHE_IDLE_TTL_SECS`.
227pub const CACHE_IDLE_TTL_ENV: &str = "MOLD_CACHE_IDLE_TTL_SECS";
228
229/// Resolve the cache idle-TTL from env, falling back to the default.
230/// Out-of-range or unparseable values log a warning and use the default.
231pub fn resolve_cache_idle_ttl_secs() -> u64 {
232    match std::env::var(CACHE_IDLE_TTL_ENV) {
233        Ok(raw) => match raw.trim().parse::<u64>() {
234            Ok(n) if (CACHE_IDLE_TTL_LOWER_SECS..=CACHE_IDLE_TTL_UPPER_SECS).contains(&n) => n,
235            Ok(n) => {
236                tracing::warn!(
237                    env = CACHE_IDLE_TTL_ENV,
238                    value = n,
239                    lower = CACHE_IDLE_TTL_LOWER_SECS,
240                    upper = CACHE_IDLE_TTL_UPPER_SECS,
241                    "ignoring out-of-range cache idle-TTL; using default"
242                );
243                DEFAULT_CACHE_IDLE_TTL_SECS
244            }
245            Err(e) => {
246                tracing::warn!(
247                    env = CACHE_IDLE_TTL_ENV,
248                    raw = %raw,
249                    error = %e,
250                    "ignoring unparseable cache idle-TTL; using default"
251                );
252                DEFAULT_CACHE_IDLE_TTL_SECS
253            }
254        },
255        Err(_) => DEFAULT_CACHE_IDLE_TTL_SECS,
256    }
257}
258
259/// Resolve the model-cache capacity from env, falling back to the default.
260/// Out-of-range or unparseable values log a warning and use the default so
261/// a typo in the env never silently shrinks the cache to an unusable size.
262pub fn resolve_max_cached_models() -> usize {
263    match std::env::var(MAX_CACHED_MODELS_ENV) {
264        Ok(raw) => match raw.trim().parse::<usize>() {
265            Ok(n) if (MAX_CACHED_MODELS_LOWER..=MAX_CACHED_MODELS_UPPER).contains(&n) => n,
266            Ok(n) => {
267                tracing::warn!(
268                    env = MAX_CACHED_MODELS_ENV,
269                    value = n,
270                    lower = MAX_CACHED_MODELS_LOWER,
271                    upper = MAX_CACHED_MODELS_UPPER,
272                    "ignoring out-of-range cache cap; using default"
273                );
274                DEFAULT_MAX_CACHED_MODELS
275            }
276            Err(e) => {
277                tracing::warn!(
278                    env = MAX_CACHED_MODELS_ENV,
279                    raw = %raw,
280                    error = %e,
281                    "ignoring unparseable cache cap; using default"
282                );
283                DEFAULT_MAX_CACHED_MODELS
284            }
285        },
286        Err(_) => DEFAULT_MAX_CACHED_MODELS,
287    }
288}
289
290impl AppState {
291    /// Create state with a pre-loaded engine (server starts with a configured model).
292    pub fn new(
293        engine: Box<dyn InferenceEngine>,
294        config: Config,
295        queue: QueueHandle,
296        gpu_pool: Arc<GpuPool>,
297        queue_capacity: usize,
298    ) -> Self {
299        let mut cache = ModelCache::new(resolve_max_cached_models());
300        cache.insert(engine, 0);
301        Self {
302            gpu_pool,
303            queue_capacity,
304            model_cache: Arc::new(Mutex::new(cache)),
305            active_generation: Arc::new(RwLock::new(None)),
306            config: Arc::new(tokio::sync::RwLock::new(config)),
307            start_time: Instant::now(),
308            model_load_lock: Arc::new(Mutex::new(())),
309            pull_lock: Arc::new(Mutex::new(())),
310            queue,
311            job_registry: JobRegistry::new(),
312            shared_pool: Arc::new(std::sync::Mutex::new(SharedPool::new())),
313            shutdown_tx: Arc::new(tokio::sync::Mutex::new(None)),
314            upscaler_cache: Arc::new(std::sync::Mutex::new(None)),
315            metadata_db: Arc::new(None),
316            chain_jobs: None,
317            downloads: DownloadQueue::new(),
318            resources: ResourceBroadcaster::new(),
319            catalog_live_cache: default_live_cache(),
320            catalog_live_civitai_base: Arc::new(CATALOG_LIVE_CIVITAI_BASE.to_string()),
321            catalog_intents: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
322        }
323    }
324
325    /// Create state with no engine (zero-config startup, models pulled on demand).
326    pub fn empty(
327        config: Config,
328        queue: QueueHandle,
329        gpu_pool: Arc<GpuPool>,
330        queue_capacity: usize,
331    ) -> Self {
332        Self {
333            gpu_pool,
334            queue_capacity,
335            model_cache: Arc::new(Mutex::new(ModelCache::new(resolve_max_cached_models()))),
336            active_generation: Arc::new(RwLock::new(None)),
337            config: Arc::new(tokio::sync::RwLock::new(config)),
338            start_time: Instant::now(),
339            model_load_lock: Arc::new(Mutex::new(())),
340            pull_lock: Arc::new(Mutex::new(())),
341            queue,
342            job_registry: JobRegistry::new(),
343            shared_pool: Arc::new(std::sync::Mutex::new(SharedPool::new())),
344            shutdown_tx: Arc::new(tokio::sync::Mutex::new(None)),
345            upscaler_cache: Arc::new(std::sync::Mutex::new(None)),
346            metadata_db: Arc::new(None),
347            chain_jobs: None,
348            downloads: DownloadQueue::new(),
349            resources: ResourceBroadcaster::new(),
350            catalog_live_cache: default_live_cache(),
351            catalog_live_civitai_base: Arc::new(CATALOG_LIVE_CIVITAI_BASE.to_string()),
352            catalog_intents: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
353        }
354    }
355
356    /// Create an empty GpuPool for testing (no GPU workers).
357    #[cfg(test)]
358    pub(crate) fn empty_gpu_pool() -> Arc<GpuPool> {
359        Arc::new(GpuPool {
360            workers: Vec::new(),
361        })
362    }
363
364    /// Alias for `empty_gpu_pool` — exposed for tests in sibling modules
365    /// (routes_test.rs, downloads_test.rs) that live in the crate but not
366    /// in the same file.
367    #[cfg(test)]
368    pub(crate) fn empty_gpu_pool_for_test() -> Arc<GpuPool> {
369        Self::empty_gpu_pool()
370    }
371
372    #[cfg(test)]
373    pub fn with_engine(engine: impl InferenceEngine + 'static) -> Self {
374        let (tx, _rx) = tokio::sync::mpsc::channel(16);
375        let queue = QueueHandle::new(tx);
376        let mut cache = ModelCache::new(resolve_max_cached_models());
377        cache.insert(Box::new(engine), 0);
378        Self {
379            gpu_pool: Self::empty_gpu_pool(),
380            queue_capacity: 200,
381            model_cache: Arc::new(Mutex::new(cache)),
382            active_generation: Arc::new(RwLock::new(None)),
383            config: Arc::new(tokio::sync::RwLock::new(Config::default())),
384            start_time: Instant::now(),
385            model_load_lock: Arc::new(Mutex::new(())),
386            pull_lock: Arc::new(Mutex::new(())),
387            queue,
388            job_registry: JobRegistry::new(),
389            shared_pool: Arc::new(std::sync::Mutex::new(SharedPool::new())),
390            shutdown_tx: Arc::new(tokio::sync::Mutex::new(None)),
391            upscaler_cache: Arc::new(std::sync::Mutex::new(None)),
392            metadata_db: Arc::new(None),
393            chain_jobs: None,
394            downloads: DownloadQueue::new(),
395            resources: ResourceBroadcaster::new(),
396            catalog_live_cache: default_live_cache(),
397            catalog_live_civitai_base: Arc::new(CATALOG_LIVE_CIVITAI_BASE.to_string()),
398            catalog_intents: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
399        }
400    }
401
402    /// Create state with a queue whose receiver is returned for testing.
403    #[cfg(test)]
404    pub fn with_engine_and_queue(
405        engine: impl InferenceEngine + 'static,
406    ) -> (Self, tokio::sync::mpsc::Receiver<GenerationJob>) {
407        let (tx, rx) = tokio::sync::mpsc::channel(16);
408        let queue = QueueHandle::new(tx);
409        let mut cache = ModelCache::new(resolve_max_cached_models());
410        cache.insert(Box::new(engine), 0);
411        let state = Self {
412            gpu_pool: Self::empty_gpu_pool(),
413            queue_capacity: 200,
414            model_cache: Arc::new(Mutex::new(cache)),
415            active_generation: Arc::new(RwLock::new(None)),
416            config: Arc::new(tokio::sync::RwLock::new(Config::default())),
417            start_time: Instant::now(),
418            model_load_lock: Arc::new(Mutex::new(())),
419            pull_lock: Arc::new(Mutex::new(())),
420            queue,
421            job_registry: JobRegistry::new(),
422            shared_pool: Arc::new(std::sync::Mutex::new(SharedPool::new())),
423            shutdown_tx: Arc::new(tokio::sync::Mutex::new(None)),
424            upscaler_cache: Arc::new(std::sync::Mutex::new(None)),
425            metadata_db: Arc::new(None),
426            chain_jobs: None,
427            downloads: DownloadQueue::new(),
428            resources: ResourceBroadcaster::new(),
429            catalog_live_cache: default_live_cache(),
430            catalog_live_civitai_base: Arc::new(CATALOG_LIVE_CIVITAI_BASE.to_string()),
431            catalog_intents: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
432        };
433        (state, rx)
434    }
435
436    /// Construct an empty AppState for integration tests. Catalog
437    /// surfaces hit live HF/Civitai (test callers point those at a
438    /// wiremock instance via `with_civitai_base`).
439    pub fn for_tests() -> Self {
440        let (tx, _rx) = tokio::sync::mpsc::channel(16);
441        let queue = QueueHandle::new(tx);
442        Self {
443            gpu_pool: Arc::new(GpuPool {
444                workers: Vec::new(),
445            }),
446            queue_capacity: 200,
447            model_cache: Arc::new(Mutex::new(ModelCache::new(resolve_max_cached_models()))),
448            active_generation: Arc::new(RwLock::new(None)),
449            config: Arc::new(tokio::sync::RwLock::new(Config::default())),
450            start_time: Instant::now(),
451            model_load_lock: Arc::new(Mutex::new(())),
452            pull_lock: Arc::new(Mutex::new(())),
453            queue,
454            job_registry: JobRegistry::new(),
455            shared_pool: Arc::new(std::sync::Mutex::new(SharedPool::new())),
456            shutdown_tx: Arc::new(tokio::sync::Mutex::new(None)),
457            upscaler_cache: Arc::new(std::sync::Mutex::new(None)),
458            metadata_db: Arc::new(None),
459            chain_jobs: None,
460            downloads: DownloadQueue::new(),
461            resources: ResourceBroadcaster::new(),
462            catalog_live_cache: default_live_cache(),
463            catalog_live_civitai_base: Arc::new(CATALOG_LIVE_CIVITAI_BASE.to_string()),
464            catalog_intents: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
465        }
466    }
467
468    /// Override the live-search Civitai base URL on an existing state.
469    /// Tests point this at a wiremock instance; production never calls
470    /// it (the `new` / `empty` constructors set the public host).
471    pub fn with_civitai_base(mut self, base: impl Into<String>) -> Self {
472        self.catalog_live_civitai_base = Arc::new(base.into());
473        self
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    #[test]
482    fn engine_snapshot_default_is_unloaded() {
483        let snap = EngineSnapshot::default();
484        assert!(snap.model_name.is_none());
485        assert!(!snap.is_loaded);
486    }
487
488    #[test]
489    fn active_generation_snapshot_stores_fields() {
490        let snap = ActiveGenerationSnapshot {
491            model: "flux-dev:q8".to_string(),
492            prompt_sha256: "abc123".to_string(),
493            started_at_unix_ms: 1700000000000,
494            started_at: std::time::Instant::now(),
495        };
496        assert_eq!(snap.model, "flux-dev:q8");
497        assert_eq!(snap.prompt_sha256, "abc123");
498        assert_eq!(snap.started_at_unix_ms, 1700000000000);
499    }
500
501    #[test]
502    fn queue_handle_pending_starts_at_zero() {
503        let (tx, _rx) = tokio::sync::mpsc::channel::<GenerationJob>(16);
504        let handle = QueueHandle::new(tx);
505        assert_eq!(handle.pending(), 0);
506    }
507
508    #[test]
509    fn upscaler_cache_starts_empty() {
510        let config = mold_core::Config::default();
511        let state = AppState::empty(
512            config,
513            QueueHandle::new(tokio::sync::mpsc::channel(1).0),
514            AppState::empty_gpu_pool(),
515            200,
516        );
517        let cache = state.upscaler_cache.lock().unwrap();
518        assert!(cache.is_none());
519    }
520
521    #[test]
522    fn upscaler_cache_cleared_by_setting_none() {
523        let config = mold_core::Config::default();
524        let state = AppState::empty(
525            config,
526            QueueHandle::new(tokio::sync::mpsc::channel(1).0),
527            AppState::empty_gpu_pool(),
528            200,
529        );
530        {
531            let mut cache = state.upscaler_cache.lock().unwrap();
532            *cache = None;
533        }
534        let cache = state.upscaler_cache.lock().unwrap();
535        assert!(cache.is_none());
536    }
537
538    #[test]
539    fn app_state_exposes_resources_broadcaster() {
540        let config = mold_core::Config::default();
541        let state = AppState::empty(
542            config,
543            QueueHandle::new(tokio::sync::mpsc::channel(1).0),
544            AppState::empty_gpu_pool(),
545            200,
546        );
547        // The broadcaster must exist and return None before any aggregator tick.
548        assert!(state.resources.latest().is_none());
549        // Subscribing must succeed (no panics).
550        let _rx = state.resources.subscribe();
551    }
552
553    /// Serializes every test that touches a process-wide env var via
554    /// `std::env::set_var` — mutating env is global state, so without this
555    /// guard parallel tests would race on the env table.
556    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
557
558    /// Set `name` to `value` (or remove it when `value` is `None`), invoke
559    /// `f`, then restore the original value. Lock-serialized so concurrent
560    /// tests don't race on the env table.
561    fn with_env<R>(name: &str, value: Option<&str>, f: impl FnOnce() -> R) -> R {
562        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
563        let prev = std::env::var(name).ok();
564        match value {
565            Some(v) => std::env::set_var(name, v),
566            None => std::env::remove_var(name),
567        }
568        let out = f();
569        match prev {
570            Some(v) => std::env::set_var(name, v),
571            None => std::env::remove_var(name),
572        }
573        out
574    }
575
576    #[test]
577    fn resolve_max_cached_uses_default_when_env_missing() {
578        let n = with_env(MAX_CACHED_MODELS_ENV, None, resolve_max_cached_models);
579        assert_eq!(n, DEFAULT_MAX_CACHED_MODELS);
580    }
581
582    #[test]
583    fn resolve_max_cached_honors_env_within_range() {
584        let n = with_env(MAX_CACHED_MODELS_ENV, Some("8"), resolve_max_cached_models);
585        assert_eq!(n, 8);
586    }
587
588    #[test]
589    fn resolve_max_cached_clamps_zero_back_to_default() {
590        let n = with_env(MAX_CACHED_MODELS_ENV, Some("0"), resolve_max_cached_models);
591        assert_eq!(n, DEFAULT_MAX_CACHED_MODELS);
592    }
593
594    #[test]
595    fn resolve_max_cached_clamps_overflow_back_to_default() {
596        let n = with_env(
597            MAX_CACHED_MODELS_ENV,
598            Some("999"),
599            resolve_max_cached_models,
600        );
601        assert_eq!(n, DEFAULT_MAX_CACHED_MODELS);
602    }
603
604    #[test]
605    fn resolve_max_cached_falls_back_when_env_unparseable() {
606        let n = with_env(
607            MAX_CACHED_MODELS_ENV,
608            Some("not-a-number"),
609            resolve_max_cached_models,
610        );
611        assert_eq!(n, DEFAULT_MAX_CACHED_MODELS);
612    }
613
614    #[test]
615    fn resolve_cache_idle_ttl_uses_default_when_env_missing() {
616        let n = with_env(CACHE_IDLE_TTL_ENV, None, resolve_cache_idle_ttl_secs);
617        assert_eq!(n, DEFAULT_CACHE_IDLE_TTL_SECS);
618    }
619
620    #[test]
621    fn resolve_cache_idle_ttl_honors_env_within_range() {
622        // 600s is comfortably inside [60, 86_400] — the resolver must echo it.
623        let n = with_env(CACHE_IDLE_TTL_ENV, Some("600"), resolve_cache_idle_ttl_secs);
624        assert_eq!(n, 600);
625    }
626
627    #[test]
628    fn resolve_cache_idle_ttl_clamps_zero_back_to_default() {
629        // 0 is below the 60s lower bound; falls back to the default with a warn log.
630        let n = with_env(CACHE_IDLE_TTL_ENV, Some("0"), resolve_cache_idle_ttl_secs);
631        assert_eq!(n, DEFAULT_CACHE_IDLE_TTL_SECS);
632    }
633
634    #[test]
635    fn resolve_cache_idle_ttl_clamps_overflow_back_to_default() {
636        // 100_000 is above the 86_400s upper bound; falls back to the default.
637        let n = with_env(
638            CACHE_IDLE_TTL_ENV,
639            Some("100000"),
640            resolve_cache_idle_ttl_secs,
641        );
642        assert_eq!(n, DEFAULT_CACHE_IDLE_TTL_SECS);
643    }
644
645    #[test]
646    fn resolve_cache_idle_ttl_falls_back_when_env_unparseable() {
647        let n = with_env(
648            CACHE_IDLE_TTL_ENV,
649            Some("not-a-number"),
650            resolve_cache_idle_ttl_secs,
651        );
652        assert_eq!(n, DEFAULT_CACHE_IDLE_TTL_SECS);
653    }
654}