Skip to main content

mold_server/
lib.rs

1pub mod auth;
2pub mod catalog_api;
3pub mod chain_job_runner;
4pub mod chain_limits;
5pub mod test_support;
6// Agent A (downloads)
7pub mod downloads;
8pub mod gpu_pool;
9pub mod gpu_worker;
10pub mod job_registry;
11pub mod logging;
12#[cfg(feature = "mdns")]
13pub mod mdns;
14mod memory_preflight;
15#[cfg(feature = "metrics")]
16pub mod metrics;
17pub mod model_cache;
18pub mod model_manager;
19pub mod queue;
20pub mod rate_limit;
21pub mod request_id;
22pub mod resources;
23pub mod routes;
24pub mod routes_chain;
25pub mod routes_chain_jobs;
26pub mod routes_config;
27mod signals;
28pub mod state;
29pub mod web_ui;
30
31#[cfg(all(test, feature = "metrics"))]
32mod metrics_test;
33#[cfg(test)]
34mod resources_test;
35#[cfg(test)]
36mod routes_test;
37
38use anyhow::Result;
39use axum::{extract::DefaultBodyLimit, middleware};
40use mold_core::types::GpuSelection;
41use mold_core::{Config, ModelPaths};
42use std::net::SocketAddr;
43use std::path::PathBuf;
44use std::sync::atomic::AtomicUsize;
45use tokio::net::TcpListener;
46use tower_http::cors::CorsLayer;
47use tower_http::trace::TraceLayer;
48use tracing::info;
49
50use state::QueueHandle;
51
52const MAX_REQUEST_BODY_BYTES: usize = 64 * 1024 * 1024;
53
54pub async fn run_server(
55    bind: &str,
56    port: u16,
57    models_dir: PathBuf,
58    gpu_selection: GpuSelection,
59    queue_size: usize,
60) -> Result<()> {
61    // Re-arm SIG_IGN for SIGPIPE. The CLI resets it to SIG_DFL in main() for
62    // clean piping of short-lived commands, but for this long-running server
63    // that is fatal — a single client dropping mid-write would kill the whole
64    // process (issue #342). With SIG_IGN, such writes surface as EPIPE and are
65    // handled per-request by hyper/axum.
66    signals::ignore_sigpipe();
67
68    Config::install_runtime_models_dir_override(models_dir.clone());
69
70    let mut config = Config::load_or_default();
71    config.models_dir = models_dir.to_string_lossy().into_owned();
72    let model_name = config.resolved_default_model();
73
74    // ── Discover and initialize GPU workers ────────────────────────────────
75    let shared_pool = std::sync::Arc::new(std::sync::Mutex::new(
76        mold_inference::shared_pool::SharedPool::new(),
77    ));
78
79    let discovered = mold_inference::device::discover_gpus();
80    let selected = mold_inference::device::filter_gpus(&discovered, &gpu_selection);
81
82    if selected.is_empty() && !discovered.is_empty() {
83        anyhow::bail!(
84            "No GPUs matched selection {:?} (discovered: {:?})",
85            gpu_selection,
86            discovered.iter().map(|g| g.ordinal).collect::<Vec<_>>()
87        );
88    }
89
90    let mut workers = Vec::new();
91    let mut _gpu_thread_handles = Vec::new();
92
93    // Per-worker channel is a tiny buffer: one in-flight plus one immediate
94    // handoff. The global queue cap is enforced by `QueueHandle` against
95    // `queue_size`; per-worker overflow triggers the dispatcher's cross-worker
96    // retry path in `run_queue_dispatcher`.
97    const PER_WORKER_CHANNEL_SIZE: usize = 2;
98
99    let max_cached = state::resolve_max_cached_models();
100    for gpu in &selected {
101        let (job_tx, job_rx) = std::sync::mpsc::sync_channel(PER_WORKER_CHANNEL_SIZE);
102        let worker = std::sync::Arc::new(gpu_pool::GpuWorker {
103            gpu: gpu.clone(),
104            model_cache: std::sync::Arc::new(std::sync::Mutex::new(model_cache::ModelCache::new(
105                max_cached,
106            ))),
107            active_generation: std::sync::Arc::new(std::sync::RwLock::new(None)),
108            model_load_lock: std::sync::Arc::new(std::sync::Mutex::new(())),
109            shared_pool: shared_pool.clone(),
110            in_flight: AtomicUsize::new(0),
111            consecutive_failures: AtomicUsize::new(0),
112            degraded_until: std::sync::RwLock::new(None),
113            job_tx,
114        });
115
116        let handle = gpu_worker::spawn_gpu_thread(worker.clone(), job_rx);
117        _gpu_thread_handles.push(handle);
118        workers.push(worker);
119    }
120
121    let gpu_pool = std::sync::Arc::new(gpu_pool::GpuPool { workers });
122
123    // Log discovered GPUs.
124    for status in gpu_pool.gpu_status() {
125        info!(
126            gpu = status.ordinal,
127            name = %status.name,
128            vram_mb = status.vram_total_bytes / 1_000_000,
129            "GPU worker ready"
130        );
131    }
132
133    if selected.is_empty() {
134        info!("no GPUs discovered — server will operate in CPU/pull-only mode");
135    }
136
137    // Concise GPU summary for the mDNS TXT record (e.g. "2xNVIDIA GeForce RTX
138    // 4090", or "cpu" when GPU-less). Computed here while `gpu_pool` is fresh.
139    #[cfg(feature = "mdns")]
140    let mdns_gpu_summary = {
141        let names: Vec<String> = gpu_pool
142            .gpu_status()
143            .iter()
144            .map(|s| s.name.clone())
145            .collect();
146        mdns::gpu_summary(&names)
147    };
148
149    // ── Create generation queue ────────────────────────────────────────────
150    let (job_tx, job_rx) = tokio::sync::mpsc::channel(queue_size.max(1));
151    let queue_handle = QueueHandle::new(job_tx);
152
153    // ── Create AppState ────────────────────────────────────────────────────
154    let mut state = if gpu_pool.worker_count() > 0 {
155        if let Some(paths) = ModelPaths::resolve(&model_name, &config) {
156            info!(model = %model_name, "configured model");
157            info!(transformer = %paths.transformer.display());
158            info!(vae = %paths.vae.display());
159            if let Some(spatial_upscaler) = &paths.spatial_upscaler {
160                info!(spatial_upscaler = %spatial_upscaler.display());
161            }
162            if let Some(t5) = &paths.t5_encoder {
163                info!(t5 = %t5.display());
164            }
165            if let Some(clip) = &paths.clip_encoder {
166                info!(clip = %clip.display());
167            }
168            if let Some(t5_tok) = &paths.t5_tokenizer {
169                info!(t5_tok = %t5_tok.display());
170            }
171            if let Some(clip_tok) = &paths.clip_tokenizer {
172                info!(clip_tok = %clip_tok.display());
173            }
174            if let Some(clip2) = &paths.clip_encoder_2 {
175                info!(clip2 = %clip2.display());
176            }
177            if let Some(clip2_tok) = &paths.clip_tokenizer_2 {
178                info!(clip2_tok = %clip2_tok.display());
179            }
180            for (i, te) in paths.text_encoder_files.iter().enumerate() {
181                info!(text_encoder_shard = i, path = %te.display());
182            }
183            if let Some(text_tok) = &paths.text_tokenizer {
184                info!(text_tok = %text_tok.display());
185            }
186            info!("multi-GPU mode defers model loading to per-GPU workers");
187        } else {
188            info!("no default model configured — models will be pulled on first request");
189        }
190        let mut state = state::AppState::empty(config, queue_handle, gpu_pool.clone(), queue_size);
191        state.shared_pool = shared_pool;
192        state
193    } else {
194        match ModelPaths::resolve(&model_name, &config) {
195            Some(paths) => {
196                info!(model = %model_name, "configured model");
197                info!(transformer = %paths.transformer.display());
198                info!(vae = %paths.vae.display());
199                if let Some(spatial_upscaler) = &paths.spatial_upscaler {
200                    info!(spatial_upscaler = %spatial_upscaler.display());
201                }
202                if let Some(t5) = &paths.t5_encoder {
203                    info!(t5 = %t5.display());
204                }
205                if let Some(clip) = &paths.clip_encoder {
206                    info!(clip = %clip.display());
207                }
208                if let Some(t5_tok) = &paths.t5_tokenizer {
209                    info!(t5_tok = %t5_tok.display());
210                }
211                if let Some(clip_tok) = &paths.clip_tokenizer {
212                    info!(clip_tok = %clip_tok.display());
213                }
214                if let Some(clip2) = &paths.clip_encoder_2 {
215                    info!(clip2 = %clip2.display());
216                }
217                if let Some(clip2_tok) = &paths.clip_tokenizer_2 {
218                    info!(clip2_tok = %clip2_tok.display());
219                }
220                for (i, te) in paths.text_encoder_files.iter().enumerate() {
221                    info!(text_encoder_shard = i, path = %te.display());
222                }
223                if let Some(text_tok) = &paths.text_tokenizer {
224                    info!(text_tok = %text_tok.display());
225                }
226
227                let offload = std::env::var("MOLD_OFFLOAD").is_ok_and(|v| v == "1");
228                let engine = mold_inference::create_engine_with_pool(
229                    model_name,
230                    paths,
231                    &config,
232                    mold_inference::LoadStrategy::Eager,
233                    0,
234                    offload,
235                    Some(shared_pool.clone()),
236                )?;
237                let mut state = state::AppState::new(
238                    engine,
239                    config,
240                    queue_handle,
241                    gpu_pool.clone(),
242                    queue_size,
243                );
244                state.shared_pool = shared_pool;
245                state
246            }
247            None => {
248                info!("no default model configured — models will be pulled on first request");
249                state::AppState::empty(config, queue_handle, gpu_pool.clone(), queue_size)
250            }
251        }
252    };
253
254    // Open the gallery metadata DB (best-effort — server still runs without it).
255    match mold_db::open_default() {
256        Ok(Some(db)) => {
257            info!(db = %db.path().display(), "metadata DB opened");
258            state.metadata_db = std::sync::Arc::new(Some(db));
259        }
260        Ok(None) => {
261            tracing::info!("metadata DB disabled (MOLD_DB_DISABLE set or MOLD_HOME unresolved)");
262        }
263        Err(e) => {
264            tracing::warn!(
265                "failed to open metadata DB: {e:#} — gallery falls back to filesystem scan"
266            );
267        }
268    }
269
270    if state.metadata_db.is_some() {
271        let Some(jobs_root) = Config::mold_dir().map(|dir| dir.join("jobs")) else {
272            anyhow::bail!("metadata DB opened but MOLD_HOME could not be resolved for chain jobs");
273        };
274        std::fs::create_dir_all(&jobs_root)?;
275        let db_arc = state.metadata_db.clone();
276        let reconcile_root = jobs_root.clone();
277        let (flipped, repaired) = tokio::task::spawn_blocking(move || {
278            let Some(db) = db_arc.as_ref().as_ref() else {
279                anyhow::bail!("metadata DB disappeared before chain reconcile");
280            };
281            chain_job_runner::startup_reconcile(db, &reconcile_root)
282        })
283        .await??;
284        tracing::info!(
285            flipped,
286            repaired,
287            jobs_root = %jobs_root.display(),
288            "chain job startup reconcile complete"
289        );
290
291        let gc_db = state.metadata_db.clone();
292        let gc_root = jobs_root.clone();
293        let startup_gc = tokio::task::spawn_blocking(move || {
294            let Some(db) = gc_db.as_ref().as_ref() else {
295                anyhow::bail!("metadata DB disappeared before chain startup GC");
296            };
297            chain_job_runner::startup_gc_sweep(db, &gc_root)
298        })
299        .await??;
300        tracing::info!(
301            swept_ephemeral_jobs = startup_gc.swept_ephemeral_jobs,
302            pruned_artifact_dirs = startup_gc.pruned_artifact_dirs,
303            jobs_root = %jobs_root.display(),
304            "chain job startup GC complete"
305        );
306
307        let config_snapshot = state.config.read().await.clone();
308        let output_dir = if config_snapshot.is_output_disabled() {
309            None
310        } else {
311            Some(config_snapshot.effective_output_dir())
312        };
313        let deps = chain_job_runner::RunnerDeps {
314            db: state.metadata_db.clone(),
315            jobs_root,
316            executor: std::sync::Arc::new(chain_job_runner::ProductionStageExecutor::new(
317                state.gpu_pool.clone(),
318                config_snapshot,
319            )),
320            queue_probe: std::sync::Arc::new(chain_job_runner::ProductionQueueProbe::new(
321                state.queue.clone(),
322                state.gpu_pool.clone(),
323            )),
324            events: std::sync::Arc::new(chain_job_runner::JobEventBus::new()),
325            cancel: std::sync::Arc::new(chain_job_runner::CancelRegistry::new()),
326            job_locks: std::sync::Arc::new(chain_job_runner::JobMutationLocks::new()),
327            claims: std::sync::Arc::new(chain_job_runner::EphemeralClaims::default()),
328            output_dir,
329        };
330        state.chain_jobs = Some(std::sync::Arc::new(chain_job_runner::spawn_runner(deps)));
331    }
332
333    // Spawn the generation queue worker — processes jobs sequentially (single GPU).
334    // Spawn queue worker: use multi-GPU dispatcher if GPUs are available,
335    // otherwise fall back to the single-threaded queue worker.
336    let worker_state = state.clone();
337    if gpu_pool.worker_count() > 0 {
338        tokio::spawn(queue::run_queue_dispatcher(job_rx, worker_state));
339    } else {
340        tokio::spawn(queue::run_queue_worker(job_rx, worker_state));
341    }
342
343    // Background idle-TTL sweeper: reclaims parked engines that haven't been
344    // touched for `MOLD_CACHE_IDLE_TTL_SECS` seconds. Abort handle bound to
345    // graceful shutdown like every other long-running task in this fn.
346    let idle_evict_handle = spawn_cache_idle_evictor(
347        state.model_cache.clone(),
348        state.model_load_lock.clone(),
349        gpu_pool.clone(),
350        std::time::Duration::from_secs(state::resolve_cache_idle_ttl_secs()),
351    );
352
353    // ── Downloads UI (Agent A) ──────────────────────────────────────────────
354    // Single-writer download queue driver. Bind the `JoinHandle` so we can
355    // `.abort()` it when `axum::serve` returns — same pattern as the resource
356    // telemetry aggregator (see commit 5e43886). Without this the task would
357    // outlive graceful shutdown and keep polling its cancellation token until
358    // process exit.
359    let downloads_shutdown = tokio_util::sync::CancellationToken::new();
360    let downloads_models_dir = state.config.read().await.resolved_models_dir();
361    let downloads_driver = crate::downloads::spawn_driver(
362        state.downloads.clone(),
363        std::sync::Arc::new(crate::downloads::HfPullDriver),
364        std::sync::Arc::new(crate::downloads::CivitaiRecipeDriver),
365        downloads_models_dir,
366        downloads_shutdown.clone(),
367    );
368
369    // Ensure output directory exists and pre-generate thumbnails.
370    {
371        let config = state.config.read().await;
372        if config.is_output_disabled() {
373            tracing::warn!(
374                "image output is disabled (output_dir is empty) — \
375                 generated images will not be saved and the TUI gallery will be empty"
376            );
377        } else {
378            let output_dir = config.effective_output_dir();
379            let _ = std::fs::create_dir_all(&output_dir);
380            info!(output_dir = %output_dir.display(), "gallery output directory");
381            routes::spawn_thumbnail_warmup(&config);
382
383            // Async reconcile: import any existing files into the DB and
384            // drop rows whose backing files are missing. Runs on a blocking
385            // worker so it never stalls the request path even on large dirs.
386            if state.metadata_db.is_some() {
387                let db_arc = state.metadata_db.clone();
388                let dir = output_dir.clone();
389                tokio::spawn(async move {
390                    let join = tokio::task::spawn_blocking(move || {
391                        if let Some(db) = db_arc.as_ref() {
392                            db.reconcile(&dir)
393                        } else {
394                            Ok(mold_db::ReconcileStats::default())
395                        }
396                    })
397                    .await;
398                    match join {
399                        Ok(Ok(stats)) => tracing::info!(
400                            imported = stats.imported,
401                            updated = stats.updated,
402                            removed = stats.removed,
403                            kept = stats.kept,
404                            "metadata DB reconciled with gallery directory"
405                        ),
406                        Ok(Err(e)) => tracing::warn!("metadata DB reconcile failed: {e:#}"),
407                        Err(e) => tracing::warn!("metadata DB reconcile task join error: {e}"),
408                    }
409                });
410            }
411        }
412    }
413
414    // Load optional auth and rate-limit configuration from env vars.
415    let auth_state = auth::load_api_keys()?;
416    // Capture whether auth is required before `auth_state` is moved into the
417    // router below — surfaced in the mDNS TXT `auth` flag.
418    #[cfg(feature = "mdns")]
419    let mdns_auth_required = auth_state.is_some();
420    let rl_config = rate_limit::load_rate_limit_config()?;
421
422    let cors = build_cors_layer()?;
423
424    // Install the Prometheus metrics recorder (when feature-enabled).
425    // Must happen before any middleware or handler that records metrics.
426    #[cfg(feature = "metrics")]
427    let prometheus_handle = metrics::install_recorder();
428
429    // Build the router with middleware layers.
430    // Order (outermost → innermost): CORS → Trace → RequestID → Metrics → Auth → RateLimit → routes
431    // All inject + enforce layers use .layer() (not .route_layer()) so they run on
432    // ALL requests, including unmatched 404 paths — preventing auth/rate-limit bypass.
433    // Set up graceful shutdown: fires on SIGTERM or POST /api/shutdown.
434    let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
435    *state.shutdown_tx.lock().await = Some(shutdown_tx);
436
437    #[cfg(unix)]
438    {
439        let sigterm_state = state.clone();
440        tokio::spawn(async move {
441            if let Ok(mut sig) =
442                tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
443            {
444                sig.recv().await;
445                tracing::info!("received SIGTERM, initiating graceful shutdown");
446                if let Some(tx) = sigterm_state.shutdown_tx.lock().await.take() {
447                    let _ = tx.send(());
448                }
449            }
450        });
451    }
452
453    // Spawn the resource telemetry aggregator (1 Hz). Keep the `JoinHandle`
454    // bound so we can `.abort()` it when `axum::serve` returns — otherwise
455    // the task outlives server shutdown and keeps ticking until process exit.
456    let resources_aggregator = resources::spawn_aggregator(state.resources.clone());
457
458    // Save start_time before state is moved into the router (needed for metrics).
459    #[cfg(feature = "metrics")]
460    let server_start_time = state.start_time;
461
462    // The /metrics endpoint is mounted outside the auth/rate-limit stack so it
463    // is always accessible for monitoring scrapers (Prometheus, Grafana Agent, etc.).
464    #[allow(unused_mut)]
465    let mut app = routes::create_router(state)
466        .merge(web_ui::router())
467        .layer(DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES))
468        .layer(middleware::from_fn(rate_limit::rate_limit_middleware))
469        .layer(middleware::from_fn_with_state(
470            rl_config,
471            rate_limit::inject_rate_limit_state,
472        ))
473        .layer(middleware::from_fn(auth::require_api_key))
474        .layer(middleware::from_fn_with_state(
475            auth_state,
476            auth::inject_auth_state,
477        ));
478
479    // HTTP metrics middleware sits outside auth so it observes all requests
480    // (including auth failures and rate-limited responses).
481    #[cfg(feature = "metrics")]
482    {
483        app = app.layer(middleware::from_fn(metrics::http_metrics_middleware));
484    }
485
486    #[cfg(feature = "metrics")]
487    {
488        let metrics_state = metrics::MetricsState {
489            handle: prometheus_handle,
490            start_time: server_start_time,
491        };
492        app = app.route(
493            "/metrics",
494            axum::routing::get(metrics::metrics_endpoint).with_state(metrics_state),
495        );
496    }
497
498    let app = app
499        .layer(middleware::from_fn(request_id::request_id_middleware))
500        .layer(TraceLayer::new_for_http())
501        .layer(cors);
502
503    let addr: SocketAddr = format!("{bind}:{port}").parse()?;
504    let version = mold_core::build_info::version_string();
505    info!(%addr, %version, "starting mold server");
506
507    let listener = TcpListener::bind(addr).await?;
508
509    // ── mDNS/DNS-SD advertising ─────────────────────────────────────────────
510    // Advertise this server as `_mold._tcp.local.` so desktop clients and
511    // `mold server discover` find it without a configured host. Uses the real
512    // bound port (supports `--port 0`), skips loopback binds, and honours the
513    // MOLD_MDNS env toggle. The guard unregisters on the shutdown path below.
514    #[cfg(feature = "mdns")]
515    let mdns_guard = {
516        let bound_port = listener.local_addr()?.port();
517        if mdns::enabled_from_env() && mdns::is_advertisable(bind, bound_port) {
518            let txt = mdns::build_txt_records(
519                mold_core::build_info::VERSION,
520                mold_core::build_info::GIT_SHA,
521                mdns_auth_required,
522                &mdns_gpu_summary,
523                queue_size,
524            );
525            match mdns::register(bind, bound_port, txt) {
526                Ok(guard) => Some(guard),
527                Err(e) => {
528                    tracing::warn!(error = %format!("{e:#}"), "mDNS advertising disabled");
529                    None
530                }
531            }
532        } else {
533            tracing::debug!("mDNS advertising skipped (disabled or loopback bind)");
534            None
535        }
536    };
537
538    axum::serve(
539        listener,
540        app.into_make_service_with_connect_info::<SocketAddr>(),
541    )
542    .with_graceful_shutdown(async {
543        let _ = shutdown_rx.await;
544        tracing::info!("shutting down");
545    })
546    .await?;
547
548    // Server has stopped accepting requests — cancel the downloads token so the
549    // driver's `wait_for_work` arm returns, then abort the JoinHandle to ensure
550    // the task is cleaned up on the same shutdown path as the HTTP server.
551    // Matches the aggregator handle pattern from commit 5e43886.
552    #[cfg(feature = "mdns")]
553    if let Some(guard) = mdns_guard {
554        guard.shutdown();
555    }
556    downloads_shutdown.cancel();
557    downloads_driver.abort();
558    idle_evict_handle.abort();
559    // Server has stopped accepting requests — stop the telemetry aggregator
560    // so it doesn't outlive the server loop.
561    resources_aggregator.abort();
562
563    Ok(())
564}
565
566/// Spawn a tokio task that wakes every 60s and drops any cache entry whose
567/// `last_used` is older than `ttl` (and that isn't actively GPU-resident).
568/// Sweeps the legacy single-GPU cache and every per-worker cache in the
569/// multi-GPU pool. Returns the `JoinHandle` so the caller can `.abort()` on
570/// shutdown.
571///
572/// After dropping evicted engines, calls `reclaim_gpu_memory` on a thread
573/// bound to the relevant GPU ordinal so the freed memory actually returns to
574/// the OS rather than sitting in CUDA's per-context caching allocator. Without
575/// this step, `nvidia-smi` shows VRAM as still-allocated to the process even
576/// after the engine struct is dropped, which is the proximate cause of "model
577/// B OOMs even though model A finished and the queue went idle."
578fn spawn_cache_idle_evictor(
579    legacy_cache: std::sync::Arc<tokio::sync::Mutex<model_cache::ModelCache>>,
580    legacy_load_lock: std::sync::Arc<tokio::sync::Mutex<()>>,
581    gpu_pool: std::sync::Arc<gpu_pool::GpuPool>,
582    ttl: std::time::Duration,
583) -> tokio::task::JoinHandle<()> {
584    use tokio::time::{interval, MissedTickBehavior};
585    tokio::spawn(async move {
586        let mut tick = interval(std::time::Duration::from_secs(60));
587        tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
588        // First tick fires immediately; skip it so a freshly-loaded model
589        // doesn't get reaped on boot before it's even been used.
590        tick.tick().await;
591        loop {
592            tick.tick().await;
593
594            // ── Legacy single-GPU cache ─────────────────────────────────────
595            //
596            // Take the legacy load lock for the full eviction+reclaim window
597            // so a generation request can't race in between us evicting and
598            // reclaiming, which would slot a fresh model load into a context
599            // we're about to reset.
600            {
601                let _load_guard = legacy_load_lock.lock().await;
602                let evicted = {
603                    let mut cache = legacy_cache.lock().await;
604                    cache.evict_idle(ttl)
605                };
606                let evicted_count = evicted.len();
607                // Drop engines OUTSIDE the cache lock — `cuMemFree` and
608                // safetensor unmap during drop can block other cache users.
609                drop(evicted);
610
611                // Only reclaim when something was evicted (nothing to flush
612                // otherwise) and when no GPU-resident engine remains
613                // (`cuDevicePrimaryCtxReset` would corrupt it). The load
614                // lock above guarantees no concurrent load can sneak one in
615                // between this check and the reclaim.
616                let legacy_active = legacy_cache.lock().await.active_model().is_some();
617                if evicted_count > 0 && !legacy_active {
618                    tokio::task::spawn_blocking(|| {
619                        mold_inference::reclaim_gpu_memory(0);
620                    })
621                    .await
622                    .ok();
623                }
624            }
625
626            // ── Multi-GPU per-worker caches ─────────────────────────────────
627            //
628            // Same pattern but per-worker: hold `worker.model_load_lock` for
629            // evict + drop + reclaim. Done under spawn_blocking because the
630            // worker locks are std mutexes and the reclaim itself is sync.
631            for worker in &gpu_pool.workers {
632                let worker = worker.clone();
633                let ttl = ttl;
634                tokio::task::spawn_blocking(move || {
635                    let _load_guard = match worker.model_load_lock.lock() {
636                        Ok(g) => g,
637                        Err(poisoned) => poisoned.into_inner(),
638                    };
639                    let evicted = {
640                        let mut cache =
641                            worker.model_cache.lock().unwrap_or_else(|e| e.into_inner());
642                        cache.evict_idle(ttl)
643                    };
644                    let evicted_count = evicted.len();
645                    drop(evicted);
646
647                    let active = worker
648                        .model_cache
649                        .lock()
650                        .map(|c| c.active_model().is_some())
651                        .unwrap_or(true);
652                    if evicted_count > 0 && !active {
653                        // Bind the thread to the worker's ordinal so
654                        // reclaim_gpu_memory's debug-assert is satisfied,
655                        // then clear so the spawn_blocking thread (which
656                        // returns to the tokio pool) doesn't carry a stale
657                        // binding.
658                        mold_inference::device::init_thread_gpu_ordinal(worker.gpu.ordinal);
659                        mold_inference::reclaim_gpu_memory(worker.gpu.ordinal);
660                        mold_inference::device::clear_thread_gpu_ordinal();
661                    }
662                })
663                .await
664                .ok();
665            }
666        }
667    })
668}
669
670fn build_cors_layer() -> Result<CorsLayer> {
671    let cors = match std::env::var("MOLD_CORS_ORIGIN") {
672        Ok(origin) if !origin.is_empty() => {
673            let origin = origin
674                .parse::<axum::http::HeaderValue>()
675                .map_err(|_| anyhow::anyhow!("invalid MOLD_CORS_ORIGIN value: {origin}"))?;
676            CorsLayer::new()
677                .allow_origin(origin)
678                .allow_methods([
679                    axum::http::Method::GET,
680                    axum::http::Method::POST,
681                    axum::http::Method::DELETE,
682                ])
683                .allow_headers(tower_http::cors::Any)
684                .expose_headers([
685                    axum::http::header::HeaderName::from_static("x-mold-seed-used"),
686                    axum::http::header::HeaderName::from_static("x-request-id"),
687                    axum::http::header::HeaderName::from_static("retry-after"),
688                    axum::http::header::HeaderName::from_static("x-mold-video-frames"),
689                    axum::http::header::HeaderName::from_static("x-mold-video-fps"),
690                    axum::http::header::HeaderName::from_static("x-mold-video-width"),
691                    axum::http::header::HeaderName::from_static("x-mold-video-height"),
692                    axum::http::header::HeaderName::from_static("x-mold-video-has-audio"),
693                    axum::http::header::HeaderName::from_static("x-mold-video-duration-ms"),
694                    axum::http::header::HeaderName::from_static("x-mold-video-audio-sample-rate"),
695                    axum::http::header::HeaderName::from_static("x-mold-video-audio-channels"),
696                    axum::http::header::HeaderName::from_static("x-mold-dimension-warning"),
697                ])
698        }
699        _ => CorsLayer::permissive(),
700    };
701    Ok(cors)
702}
703
704#[cfg(test)]
705mod tests {
706    use super::build_cors_layer;
707    use std::sync::Mutex;
708
709    static ENV_LOCK: Mutex<()> = Mutex::new(());
710
711    #[test]
712    fn invalid_cors_origin_returns_error() {
713        let _lock = ENV_LOCK.lock().unwrap();
714        std::env::set_var("MOLD_CORS_ORIGIN", "\nnot-a-header");
715        let result = build_cors_layer();
716        std::env::remove_var("MOLD_CORS_ORIGIN");
717        assert!(result.is_err());
718    }
719
720    #[test]
721    fn valid_cors_origin_builds_layer() {
722        let _lock = ENV_LOCK.lock().unwrap();
723        std::env::set_var("MOLD_CORS_ORIGIN", "https://example.com");
724        let result = build_cors_layer();
725        std::env::remove_var("MOLD_CORS_ORIGIN");
726        assert!(result.is_ok());
727    }
728}