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
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
48pub enum SseCompletionPayload {
49 #[default]
50 Full,
51 MetadataOnly,
52}
53
54pub struct GenerationJob {
56 pub id: String,
64 pub request: mold_core::GenerateRequest,
65 pub completion_payload: SseCompletionPayload,
66 pub progress_tx: Option<tokio::sync::mpsc::UnboundedSender<SseMessage>>,
68 pub result_tx: tokio::sync::oneshot::Sender<Result<GenerationJobResult, String>>,
70 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#[derive(Clone)]
81pub struct QueueHandle {
82 job_tx: tokio::sync::mpsc::Sender<GenerationJob>,
83 pending_count: Arc<AtomicUsize>,
84}
85
86#[derive(Debug)]
88pub enum SubmitError {
89 Full { pending: usize, capacity: usize },
91 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 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#[derive(Clone)]
141pub struct AppState {
142 pub instance_id: Arc<String>,
147 pub gpu_pool: Arc<GpuPool>,
150 pub queue_capacity: usize,
152
153 pub model_cache: Arc<Mutex<ModelCache>>,
155 pub active_generation: Arc<RwLock<Option<ActiveGenerationSnapshot>>>,
159 pub config: Arc<tokio::sync::RwLock<Config>>,
160 pub start_time: Instant,
161 pub model_load_lock: Arc<Mutex<()>>,
163 pub pull_lock: Arc<Mutex<()>>,
165 pub queue: QueueHandle,
167 pub job_registry: SharedJobRegistry,
172 pub queue_pause: Arc<crate::queue::QueuePause>,
176 pub shared_pool: Arc<std::sync::Mutex<SharedPool>>,
178 pub shutdown_tx: Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>,
180 pub upscaler_cache: Arc<std::sync::Mutex<Option<Box<dyn mold_inference::UpscaleEngine>>>>,
182 pub metadata_db: Arc<Option<mold_db::MetadataDb>>,
186 pub chain_jobs: Option<Arc<crate::chain_job_runner::ChainJobRunnerHandle>>,
189 pub downloads: Arc<DownloadQueue>,
192 pub resources: Arc<ResourceBroadcaster>,
194 pub events: Arc<EventBroadcaster>,
198 pub catalog_live_cache: mold_catalog::live::LiveCache,
201 pub catalog_live_civitai_base: Arc<String>,
204 pub catalog_intents: Arc<
209 tokio::sync::RwLock<
210 std::collections::HashMap<String, mold_catalog::synthesis::CatalogModelIntent>,
211 >,
212 >,
213 pub models_disk_cache: Arc<ModelsDiskCache>,
217}
218
219pub const MODELS_DISK_TTL: std::time::Duration = std::time::Duration::from_secs(15);
222
223#[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 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 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 pub fn release(&self) {
279 self.refreshing
280 .store(false, std::sync::atomic::Ordering::Release);
281 }
282}
283
284const CATALOG_LIVE_CACHE_TTL_SECS: u64 = 300;
288const CATALOG_LIVE_CACHE_MAX_KEYS: usize = 256;
291pub 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
302pub const DEFAULT_MAX_CACHED_MODELS: usize = 3;
304const MAX_CACHED_MODELS_LOWER: usize = 1;
308const MAX_CACHED_MODELS_UPPER: usize = 16;
309pub const MAX_CACHED_MODELS_ENV: &str = "MOLD_MAX_CACHED_MODELS";
311
312pub 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;
319pub const CACHE_IDLE_TTL_ENV: &str = "MOLD_CACHE_IDLE_TTL_SECS";
321
322pub 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
352pub 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 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 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 #[cfg(test)]
461 pub(crate) fn empty_gpu_pool() -> Arc<GpuPool> {
462 Arc::new(GpuPool {
463 workers: Vec::new(),
464 })
465 }
466
467 #[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 #[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 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 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 let (usage, refresh) = cache.read();
605 assert_eq!(usage, None);
606 assert!(refresh, "first reader must win the refresh claim");
607 let (usage, refresh) = cache.read();
610 assert_eq!(usage, None);
611 assert!(!refresh, "claim must not be handed out twice");
612
613 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 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 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 assert!(state.resources.latest().is_none());
715 let _rx = state.resources.subscribe();
717 }
718
719 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
723
724 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 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 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 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}