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