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;
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 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 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 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 let (job_tx, job_rx) = tokio::sync::mpsc::channel(queue_size.max(1));
126 let queue_handle = QueueHandle::new(job_tx);
127
128 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 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 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 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 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 {
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 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 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 #[cfg(feature = "metrics")]
335 let prometheus_handle = metrics::install_recorder();
336
337 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 let resources_aggregator = resources::spawn_aggregator(state.resources.clone());
365
366 #[cfg(feature = "metrics")]
368 let server_start_time = state.start_time;
369
370 #[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 #[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 downloads_shutdown.cancel();
431 downloads_driver.abort();
432 idle_evict_handle.abort();
433 resources_aggregator.abort();
436
437 Ok(())
438}
439
440fn 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 tick.tick().await;
465 loop {
466 tick.tick().await;
467
468 {
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(evicted);
484
485 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 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 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}