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 pub model_name: Option<String>,
22 pub is_loaded: bool,
23 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
35pub 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
45pub struct GenerationJob {
47 pub id: String,
55 pub request: mold_core::GenerateRequest,
56 pub progress_tx: Option<tokio::sync::mpsc::UnboundedSender<SseMessage>>,
58 pub result_tx: tokio::sync::oneshot::Sender<Result<GenerationJobResult, String>>,
60 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#[derive(Clone)]
71pub struct QueueHandle {
72 job_tx: tokio::sync::mpsc::Sender<GenerationJob>,
73 pending_count: Arc<AtomicUsize>,
74}
75
76#[derive(Debug)]
78pub enum SubmitError {
79 Full { pending: usize, capacity: usize },
81 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 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#[derive(Clone)]
131pub struct AppState {
132 pub instance_id: Arc<String>,
137 pub gpu_pool: Arc<GpuPool>,
140 pub queue_capacity: usize,
142
143 pub model_cache: Arc<Mutex<ModelCache>>,
145 pub active_generation: Arc<RwLock<Option<ActiveGenerationSnapshot>>>,
149 pub config: Arc<tokio::sync::RwLock<Config>>,
150 pub start_time: Instant,
151 pub model_load_lock: Arc<Mutex<()>>,
153 pub pull_lock: Arc<Mutex<()>>,
155 pub queue: QueueHandle,
157 pub job_registry: SharedJobRegistry,
162 pub queue_pause: Arc<crate::queue::QueuePause>,
166 pub shared_pool: Arc<std::sync::Mutex<SharedPool>>,
168 pub shutdown_tx: Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
170 pub upscaler_cache: Arc<std::sync::Mutex<Option<Box<dyn mold_inference::UpscaleEngine>>>>,
172 pub metadata_db: Arc<Option<mold_db::MetadataDb>>,
176 pub chain_jobs: Option<Arc<crate::chain_job_runner::ChainJobRunnerHandle>>,
179 pub downloads: Arc<DownloadQueue>,
182 pub resources: Arc<ResourceBroadcaster>,
184 pub events: Arc<EventBroadcaster>,
188 pub catalog_live_cache: mold_catalog::live::LiveCache,
191 pub catalog_live_civitai_base: Arc<String>,
194 pub catalog_intents: Arc<
199 tokio::sync::RwLock<
200 std::collections::HashMap<String, mold_catalog::synthesis::CatalogModelIntent>,
201 >,
202 >,
203 pub models_disk_cache: Arc<ModelsDiskCache>,
207}
208
209pub const MODELS_DISK_TTL: std::time::Duration = std::time::Duration::from_secs(15);
212
213#[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 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 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 pub fn release(&self) {
269 self.refreshing
270 .store(false, std::sync::atomic::Ordering::Release);
271 }
272}
273
274const CATALOG_LIVE_CACHE_TTL_SECS: u64 = 300;
278const CATALOG_LIVE_CACHE_MAX_KEYS: usize = 256;
281pub 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
292pub const DEFAULT_MAX_CACHED_MODELS: usize = 3;
294const MAX_CACHED_MODELS_LOWER: usize = 1;
298const MAX_CACHED_MODELS_UPPER: usize = 16;
299pub const MAX_CACHED_MODELS_ENV: &str = "MOLD_MAX_CACHED_MODELS";
301
302pub 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;
309pub const CACHE_IDLE_TTL_ENV: &str = "MOLD_CACHE_IDLE_TTL_SECS";
311
312pub 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
342pub 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 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 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 #[cfg(test)]
451 pub(crate) fn empty_gpu_pool() -> Arc<GpuPool> {
452 Arc::new(GpuPool {
453 workers: Vec::new(),
454 })
455 }
456
457 #[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 #[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 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 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 let (usage, refresh) = cache.read();
595 assert_eq!(usage, None);
596 assert!(refresh, "first reader must win the refresh claim");
597 let (usage, refresh) = cache.read();
600 assert_eq!(usage, None);
601 assert!(!refresh, "claim must not be handed out twice");
602
603 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 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 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 assert!(state.resources.latest().is_none());
705 let _rx = state.resources.subscribe();
707 }
708
709 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
713
714 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 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 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 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}