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