Skip to main content

mold_server/
lib.rs

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