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