1pub mod auth;
2pub mod catalog_api;
3pub mod chain_limits;
4pub mod test_support;
5pub mod downloads;
7pub mod gpu_pool;
8pub mod gpu_worker;
9pub mod job_registry;
10pub mod logging;
11#[cfg(feature = "metrics")]
12pub mod metrics;
13pub mod model_cache;
14pub mod model_manager;
15pub mod queue;
16pub mod rate_limit;
17pub mod request_id;
18pub mod resources;
19pub mod routes;
20pub mod routes_chain;
21pub mod state;
22pub mod web_ui;
23
24#[cfg(all(test, feature = "metrics"))]
25mod metrics_test;
26#[cfg(test)]
27mod resources_test;
28#[cfg(test)]
29mod routes_test;
30
31use anyhow::Result;
32use axum::{extract::DefaultBodyLimit, middleware};
33use mold_core::types::GpuSelection;
34use mold_core::{Config, ModelPaths};
35use std::net::SocketAddr;
36use std::path::PathBuf;
37use std::sync::atomic::AtomicUsize;
38use tokio::net::TcpListener;
39use tower_http::cors::CorsLayer;
40use tower_http::trace::TraceLayer;
41use tracing::info;
42
43use state::QueueHandle;
44
45const MAX_REQUEST_BODY_BYTES: usize = 64 * 1024 * 1024;
46
47pub async fn run_server(
48 bind: &str,
49 port: u16,
50 models_dir: PathBuf,
51 gpu_selection: GpuSelection,
52 queue_size: usize,
53) -> Result<()> {
54 Config::install_runtime_models_dir_override(models_dir.clone());
55
56 let mut config = Config::load_or_default();
57 config.models_dir = models_dir.to_string_lossy().into_owned();
58 let model_name = config.resolved_default_model();
59
60 let shared_pool = std::sync::Arc::new(std::sync::Mutex::new(
62 mold_inference::shared_pool::SharedPool::new(),
63 ));
64
65 let discovered = mold_inference::device::discover_gpus();
66 let selected = mold_inference::device::filter_gpus(&discovered, &gpu_selection);
67
68 if selected.is_empty() && !discovered.is_empty() {
69 anyhow::bail!(
70 "No GPUs matched selection {:?} (discovered: {:?})",
71 gpu_selection,
72 discovered.iter().map(|g| g.ordinal).collect::<Vec<_>>()
73 );
74 }
75
76 let mut workers = Vec::new();
77 let mut _gpu_thread_handles = Vec::new();
78
79 const PER_WORKER_CHANNEL_SIZE: usize = 2;
84
85 let max_cached = state::resolve_max_cached_models();
86 for gpu in &selected {
87 let (job_tx, job_rx) = std::sync::mpsc::sync_channel(PER_WORKER_CHANNEL_SIZE);
88 let worker = std::sync::Arc::new(gpu_pool::GpuWorker {
89 gpu: gpu.clone(),
90 model_cache: std::sync::Arc::new(std::sync::Mutex::new(model_cache::ModelCache::new(
91 max_cached,
92 ))),
93 active_generation: std::sync::Arc::new(std::sync::RwLock::new(None)),
94 model_load_lock: std::sync::Arc::new(std::sync::Mutex::new(())),
95 shared_pool: shared_pool.clone(),
96 in_flight: AtomicUsize::new(0),
97 consecutive_failures: AtomicUsize::new(0),
98 degraded_until: std::sync::RwLock::new(None),
99 job_tx,
100 });
101
102 let handle = gpu_worker::spawn_gpu_thread(worker.clone(), job_rx);
103 _gpu_thread_handles.push(handle);
104 workers.push(worker);
105 }
106
107 let gpu_pool = std::sync::Arc::new(gpu_pool::GpuPool { workers });
108
109 for status in gpu_pool.gpu_status() {
111 info!(
112 gpu = status.ordinal,
113 name = %status.name,
114 vram_mb = status.vram_total_bytes / 1_000_000,
115 "GPU worker ready"
116 );
117 }
118
119 if selected.is_empty() {
120 info!("no GPUs discovered — server will operate in CPU/pull-only mode");
121 }
122
123 let (job_tx, job_rx) = tokio::sync::mpsc::channel(queue_size.max(1));
125 let queue_handle = QueueHandle::new(job_tx);
126
127 let mut state = if gpu_pool.worker_count() > 0 {
129 if let Some(paths) = ModelPaths::resolve(&model_name, &config) {
130 info!(model = %model_name, "configured model");
131 info!(transformer = %paths.transformer.display());
132 info!(vae = %paths.vae.display());
133 if let Some(spatial_upscaler) = &paths.spatial_upscaler {
134 info!(spatial_upscaler = %spatial_upscaler.display());
135 }
136 if let Some(t5) = &paths.t5_encoder {
137 info!(t5 = %t5.display());
138 }
139 if let Some(clip) = &paths.clip_encoder {
140 info!(clip = %clip.display());
141 }
142 if let Some(t5_tok) = &paths.t5_tokenizer {
143 info!(t5_tok = %t5_tok.display());
144 }
145 if let Some(clip_tok) = &paths.clip_tokenizer {
146 info!(clip_tok = %clip_tok.display());
147 }
148 if let Some(clip2) = &paths.clip_encoder_2 {
149 info!(clip2 = %clip2.display());
150 }
151 if let Some(clip2_tok) = &paths.clip_tokenizer_2 {
152 info!(clip2_tok = %clip2_tok.display());
153 }
154 for (i, te) in paths.text_encoder_files.iter().enumerate() {
155 info!(text_encoder_shard = i, path = %te.display());
156 }
157 if let Some(text_tok) = &paths.text_tokenizer {
158 info!(text_tok = %text_tok.display());
159 }
160 info!("multi-GPU mode defers model loading to per-GPU workers");
161 } else {
162 info!("no default model configured — models will be pulled on first request");
163 }
164 let mut state = state::AppState::empty(config, queue_handle, gpu_pool.clone(), queue_size);
165 state.shared_pool = shared_pool;
166 state
167 } else {
168 match ModelPaths::resolve(&model_name, &config) {
169 Some(paths) => {
170 info!(model = %model_name, "configured model");
171 info!(transformer = %paths.transformer.display());
172 info!(vae = %paths.vae.display());
173 if let Some(spatial_upscaler) = &paths.spatial_upscaler {
174 info!(spatial_upscaler = %spatial_upscaler.display());
175 }
176 if let Some(t5) = &paths.t5_encoder {
177 info!(t5 = %t5.display());
178 }
179 if let Some(clip) = &paths.clip_encoder {
180 info!(clip = %clip.display());
181 }
182 if let Some(t5_tok) = &paths.t5_tokenizer {
183 info!(t5_tok = %t5_tok.display());
184 }
185 if let Some(clip_tok) = &paths.clip_tokenizer {
186 info!(clip_tok = %clip_tok.display());
187 }
188 if let Some(clip2) = &paths.clip_encoder_2 {
189 info!(clip2 = %clip2.display());
190 }
191 if let Some(clip2_tok) = &paths.clip_tokenizer_2 {
192 info!(clip2_tok = %clip2_tok.display());
193 }
194 for (i, te) in paths.text_encoder_files.iter().enumerate() {
195 info!(text_encoder_shard = i, path = %te.display());
196 }
197 if let Some(text_tok) = &paths.text_tokenizer {
198 info!(text_tok = %text_tok.display());
199 }
200
201 let offload = std::env::var("MOLD_OFFLOAD").is_ok_and(|v| v == "1");
202 let engine = mold_inference::create_engine_with_pool(
203 model_name,
204 paths,
205 &config,
206 mold_inference::LoadStrategy::Eager,
207 0,
208 offload,
209 Some(shared_pool.clone()),
210 )?;
211 let mut state = state::AppState::new(
212 engine,
213 config,
214 queue_handle,
215 gpu_pool.clone(),
216 queue_size,
217 );
218 state.shared_pool = shared_pool;
219 state
220 }
221 None => {
222 info!("no default model configured — models will be pulled on first request");
223 state::AppState::empty(config, queue_handle, gpu_pool.clone(), queue_size)
224 }
225 }
226 };
227
228 match mold_db::open_default() {
230 Ok(Some(db)) => {
231 info!(db = %db.path().display(), "metadata DB opened");
232 state.metadata_db = std::sync::Arc::new(Some(db));
233 }
234 Ok(None) => {
235 tracing::info!("metadata DB disabled (MOLD_DB_DISABLE set or MOLD_HOME unresolved)");
236 }
237 Err(e) => {
238 tracing::warn!(
239 "failed to open metadata DB: {e:#} — gallery falls back to filesystem scan"
240 );
241 }
242 }
243
244 let worker_state = state.clone();
248 if gpu_pool.worker_count() > 0 {
249 tokio::spawn(queue::run_queue_dispatcher(job_rx, worker_state));
250 } else {
251 tokio::spawn(queue::run_queue_worker(job_rx, worker_state));
252 }
253
254 let idle_evict_handle = spawn_cache_idle_evictor(
258 state.model_cache.clone(),
259 state.model_load_lock.clone(),
260 gpu_pool.clone(),
261 std::time::Duration::from_secs(state::resolve_cache_idle_ttl_secs()),
262 );
263
264 let downloads_shutdown = tokio_util::sync::CancellationToken::new();
271 let downloads_models_dir = state.config.read().await.resolved_models_dir();
272 let downloads_driver = crate::downloads::spawn_driver(
273 state.downloads.clone(),
274 std::sync::Arc::new(crate::downloads::HfPullDriver),
275 std::sync::Arc::new(crate::downloads::CivitaiRecipeDriver),
276 downloads_models_dir,
277 downloads_shutdown.clone(),
278 );
279
280 {
282 let config = state.config.read().await;
283 if config.is_output_disabled() {
284 tracing::warn!(
285 "image output is disabled (output_dir is empty) — \
286 generated images will not be saved and the TUI gallery will be empty"
287 );
288 } else {
289 let output_dir = config.effective_output_dir();
290 let _ = std::fs::create_dir_all(&output_dir);
291 info!(output_dir = %output_dir.display(), "gallery output directory");
292 routes::spawn_thumbnail_warmup(&config);
293
294 if state.metadata_db.is_some() {
298 let db_arc = state.metadata_db.clone();
299 let dir = output_dir.clone();
300 tokio::spawn(async move {
301 let join = tokio::task::spawn_blocking(move || {
302 if let Some(db) = db_arc.as_ref() {
303 db.reconcile(&dir)
304 } else {
305 Ok(mold_db::ReconcileStats::default())
306 }
307 })
308 .await;
309 match join {
310 Ok(Ok(stats)) => tracing::info!(
311 imported = stats.imported,
312 updated = stats.updated,
313 removed = stats.removed,
314 kept = stats.kept,
315 "metadata DB reconciled with gallery directory"
316 ),
317 Ok(Err(e)) => tracing::warn!("metadata DB reconcile failed: {e:#}"),
318 Err(e) => tracing::warn!("metadata DB reconcile task join error: {e}"),
319 }
320 });
321 }
322 }
323 }
324
325 let auth_state = auth::load_api_keys()?;
327 let rl_config = rate_limit::load_rate_limit_config()?;
328
329 let cors = build_cors_layer()?;
330
331 #[cfg(feature = "metrics")]
334 let prometheus_handle = metrics::install_recorder();
335
336 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
342 *state.shutdown_tx.lock().await = Some(shutdown_tx);
343
344 #[cfg(unix)]
345 {
346 let sigterm_state = state.clone();
347 tokio::spawn(async move {
348 if let Ok(mut sig) =
349 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
350 {
351 sig.recv().await;
352 tracing::info!("received SIGTERM, initiating graceful shutdown");
353 if let Some(tx) = sigterm_state.shutdown_tx.lock().await.take() {
354 let _ = tx.send(());
355 }
356 }
357 });
358 }
359
360 let resources_aggregator = resources::spawn_aggregator(state.resources.clone());
364
365 #[cfg(feature = "metrics")]
367 let server_start_time = state.start_time;
368
369 #[allow(unused_mut)]
372 let mut app = routes::create_router(state)
373 .merge(web_ui::router())
374 .layer(DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES))
375 .layer(middleware::from_fn(rate_limit::rate_limit_middleware))
376 .layer(middleware::from_fn_with_state(
377 rl_config,
378 rate_limit::inject_rate_limit_state,
379 ))
380 .layer(middleware::from_fn(auth::require_api_key))
381 .layer(middleware::from_fn_with_state(
382 auth_state,
383 auth::inject_auth_state,
384 ));
385
386 #[cfg(feature = "metrics")]
389 {
390 app = app.layer(middleware::from_fn(metrics::http_metrics_middleware));
391 }
392
393 #[cfg(feature = "metrics")]
394 {
395 let metrics_state = metrics::MetricsState {
396 handle: prometheus_handle,
397 start_time: server_start_time,
398 };
399 app = app.route(
400 "/metrics",
401 axum::routing::get(metrics::metrics_endpoint).with_state(metrics_state),
402 );
403 }
404
405 let app = app
406 .layer(middleware::from_fn(request_id::request_id_middleware))
407 .layer(TraceLayer::new_for_http())
408 .layer(cors);
409
410 let addr: SocketAddr = format!("{bind}:{port}").parse()?;
411 let version = mold_core::build_info::version_string();
412 info!(%addr, %version, "starting mold server");
413
414 let listener = TcpListener::bind(addr).await?;
415 axum::serve(
416 listener,
417 app.into_make_service_with_connect_info::<SocketAddr>(),
418 )
419 .with_graceful_shutdown(async {
420 let _ = shutdown_rx.await;
421 tracing::info!("shutting down");
422 })
423 .await?;
424
425 downloads_shutdown.cancel();
430 downloads_driver.abort();
431 idle_evict_handle.abort();
432 resources_aggregator.abort();
435
436 Ok(())
437}
438
439fn spawn_cache_idle_evictor(
452 legacy_cache: std::sync::Arc<tokio::sync::Mutex<model_cache::ModelCache>>,
453 legacy_load_lock: std::sync::Arc<tokio::sync::Mutex<()>>,
454 gpu_pool: std::sync::Arc<gpu_pool::GpuPool>,
455 ttl: std::time::Duration,
456) -> tokio::task::JoinHandle<()> {
457 use tokio::time::{interval, MissedTickBehavior};
458 tokio::spawn(async move {
459 let mut tick = interval(std::time::Duration::from_secs(60));
460 tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
461 tick.tick().await;
464 loop {
465 tick.tick().await;
466
467 {
474 let _load_guard = legacy_load_lock.lock().await;
475 let evicted = {
476 let mut cache = legacy_cache.lock().await;
477 cache.evict_idle(ttl)
478 };
479 let evicted_count = evicted.len();
480 drop(evicted);
483
484 let legacy_active = legacy_cache.lock().await.active_model().is_some();
490 if evicted_count > 0 && !legacy_active {
491 tokio::task::spawn_blocking(|| {
492 mold_inference::reclaim_gpu_memory(0);
493 })
494 .await
495 .ok();
496 }
497 }
498
499 for worker in &gpu_pool.workers {
505 let worker = worker.clone();
506 let ttl = ttl;
507 tokio::task::spawn_blocking(move || {
508 let _load_guard = match worker.model_load_lock.lock() {
509 Ok(g) => g,
510 Err(poisoned) => poisoned.into_inner(),
511 };
512 let evicted = {
513 let mut cache =
514 worker.model_cache.lock().unwrap_or_else(|e| e.into_inner());
515 cache.evict_idle(ttl)
516 };
517 let evicted_count = evicted.len();
518 drop(evicted);
519
520 let active = worker
521 .model_cache
522 .lock()
523 .map(|c| c.active_model().is_some())
524 .unwrap_or(true);
525 if evicted_count > 0 && !active {
526 mold_inference::device::init_thread_gpu_ordinal(worker.gpu.ordinal);
532 mold_inference::reclaim_gpu_memory(worker.gpu.ordinal);
533 mold_inference::device::clear_thread_gpu_ordinal();
534 }
535 })
536 .await
537 .ok();
538 }
539 }
540 })
541}
542
543fn build_cors_layer() -> Result<CorsLayer> {
544 let cors = match std::env::var("MOLD_CORS_ORIGIN") {
545 Ok(origin) if !origin.is_empty() => {
546 let origin = origin
547 .parse::<axum::http::HeaderValue>()
548 .map_err(|_| anyhow::anyhow!("invalid MOLD_CORS_ORIGIN value: {origin}"))?;
549 CorsLayer::new()
550 .allow_origin(origin)
551 .allow_methods([
552 axum::http::Method::GET,
553 axum::http::Method::POST,
554 axum::http::Method::DELETE,
555 ])
556 .allow_headers(tower_http::cors::Any)
557 .expose_headers([
558 axum::http::header::HeaderName::from_static("x-mold-seed-used"),
559 axum::http::header::HeaderName::from_static("x-request-id"),
560 axum::http::header::HeaderName::from_static("retry-after"),
561 axum::http::header::HeaderName::from_static("x-mold-video-frames"),
562 axum::http::header::HeaderName::from_static("x-mold-video-fps"),
563 axum::http::header::HeaderName::from_static("x-mold-video-width"),
564 axum::http::header::HeaderName::from_static("x-mold-video-height"),
565 axum::http::header::HeaderName::from_static("x-mold-video-has-audio"),
566 axum::http::header::HeaderName::from_static("x-mold-video-duration-ms"),
567 axum::http::header::HeaderName::from_static("x-mold-video-audio-sample-rate"),
568 axum::http::header::HeaderName::from_static("x-mold-video-audio-channels"),
569 axum::http::header::HeaderName::from_static("x-mold-dimension-warning"),
570 ])
571 }
572 _ => CorsLayer::permissive(),
573 };
574 Ok(cors)
575}
576
577#[cfg(test)]
578mod tests {
579 use super::build_cors_layer;
580 use std::sync::Mutex;
581
582 static ENV_LOCK: Mutex<()> = Mutex::new(());
583
584 #[test]
585 fn invalid_cors_origin_returns_error() {
586 let _lock = ENV_LOCK.lock().unwrap();
587 std::env::set_var("MOLD_CORS_ORIGIN", "\nnot-a-header");
588 let result = build_cors_layer();
589 std::env::remove_var("MOLD_CORS_ORIGIN");
590 assert!(result.is_err());
591 }
592
593 #[test]
594 fn valid_cors_origin_builds_layer() {
595 let _lock = ENV_LOCK.lock().unwrap();
596 std::env::set_var("MOLD_CORS_ORIGIN", "https://example.com");
597 let result = build_cors_layer();
598 std::env::remove_var("MOLD_CORS_ORIGIN");
599 assert!(result.is_ok());
600 }
601}