1pub mod auth;
2pub mod catalog_api;
3pub mod chain_job_runner;
4pub mod chain_limits;
5pub mod test_support;
6pub mod downloads;
8pub mod events;
9pub mod gpu_pool;
10pub mod gpu_worker;
11pub mod job_registry;
12pub mod logging;
13#[cfg(feature = "mdns")]
14pub mod mdns;
15mod memory_preflight;
16#[cfg(feature = "metrics")]
17pub mod metrics;
18pub mod model_cache;
19pub mod model_manager;
20pub mod queue;
21pub mod rate_limit;
22pub mod request_id;
23pub mod resources;
24pub mod routes;
25pub mod routes_chain;
26pub mod routes_chain_jobs;
27pub mod routes_config;
28mod signals;
29pub mod state;
30pub mod web_ui;
31
32#[cfg(all(test, feature = "metrics"))]
33mod metrics_test;
34#[cfg(test)]
35mod resources_test;
36#[cfg(test)]
37mod routes_test;
38
39use anyhow::Result;
40use axum::{extract::DefaultBodyLimit, middleware};
41use mold_core::types::GpuSelection;
42use mold_core::{Config, ModelPaths};
43use std::net::SocketAddr;
44use std::path::PathBuf;
45use std::sync::atomic::AtomicUsize;
46use tokio::net::TcpListener;
47use tower_http::cors::CorsLayer;
48use tower_http::trace::TraceLayer;
49use tracing::info;
50
51use state::QueueHandle;
52
53const MAX_REQUEST_BODY_BYTES: usize = 64 * 1024 * 1024;
54
55pub async fn run_server(
56 bind: &str,
57 port: u16,
58 models_dir: PathBuf,
59 gpu_selection: GpuSelection,
60 queue_size: usize,
61) -> Result<()> {
62 signals::ignore_sigpipe();
68
69 Config::install_runtime_models_dir_override(models_dir.clone());
70
71 let mut config = Config::load_or_default();
72 config.models_dir = models_dir.to_string_lossy().into_owned();
73 let model_name = config.resolved_default_model();
74
75 let shared_pool = std::sync::Arc::new(std::sync::Mutex::new(
77 mold_inference::shared_pool::SharedPool::new(),
78 ));
79
80 let discovered = mold_inference::device::discover_gpus();
81 let selected = mold_inference::device::filter_gpus(&discovered, &gpu_selection);
82
83 if selected.is_empty() && !discovered.is_empty() {
84 anyhow::bail!(
85 "No GPUs matched selection {:?} (discovered: {:?})",
86 gpu_selection,
87 discovered.iter().map(|g| g.ordinal).collect::<Vec<_>>()
88 );
89 }
90
91 let mut workers = Vec::new();
92 let mut _gpu_thread_handles = Vec::new();
93
94 const PER_WORKER_CHANNEL_SIZE: usize = 2;
99
100 let max_cached = state::resolve_max_cached_models();
101 for gpu in &selected {
102 let (job_tx, job_rx) = std::sync::mpsc::sync_channel(PER_WORKER_CHANNEL_SIZE);
103 let worker = std::sync::Arc::new(gpu_pool::GpuWorker {
104 gpu: gpu.clone(),
105 model_cache: std::sync::Arc::new(std::sync::Mutex::new(model_cache::ModelCache::new(
106 max_cached,
107 ))),
108 active_generation: std::sync::Arc::new(std::sync::RwLock::new(None)),
109 model_load_lock: std::sync::Arc::new(std::sync::Mutex::new(())),
110 shared_pool: shared_pool.clone(),
111 in_flight: AtomicUsize::new(0),
112 consecutive_failures: AtomicUsize::new(0),
113 degraded_until: std::sync::RwLock::new(None),
114 job_tx,
115 });
116
117 let handle = gpu_worker::spawn_gpu_thread(worker.clone(), job_rx);
118 _gpu_thread_handles.push(handle);
119 workers.push(worker);
120 }
121
122 let gpu_pool = std::sync::Arc::new(gpu_pool::GpuPool { workers });
123
124 for status in gpu_pool.gpu_status() {
126 info!(
127 gpu = status.ordinal,
128 name = %status.name,
129 vram_mb = status.vram_total_bytes / 1_000_000,
130 "GPU worker ready"
131 );
132 }
133
134 if selected.is_empty() {
135 info!("no GPUs discovered — server will operate in CPU/pull-only mode");
136 }
137
138 #[cfg(feature = "mdns")]
141 let mdns_gpu_summary = {
142 let names: Vec<String> = gpu_pool
143 .gpu_status()
144 .iter()
145 .map(|s| s.name.clone())
146 .collect();
147 mdns::gpu_summary(&names)
148 };
149
150 let (job_tx, job_rx) = tokio::sync::mpsc::channel(queue_size.max(1));
152 let queue_handle = QueueHandle::new(job_tx);
153
154 let mut state = if gpu_pool.worker_count() > 0 {
156 if let Some(paths) = ModelPaths::resolve(&model_name, &config) {
157 info!(model = %model_name, "configured model");
158 info!(transformer = %paths.transformer.display());
159 info!(vae = %paths.vae.display());
160 if let Some(spatial_upscaler) = &paths.spatial_upscaler {
161 info!(spatial_upscaler = %spatial_upscaler.display());
162 }
163 if let Some(t5) = &paths.t5_encoder {
164 info!(t5 = %t5.display());
165 }
166 if let Some(clip) = &paths.clip_encoder {
167 info!(clip = %clip.display());
168 }
169 if let Some(t5_tok) = &paths.t5_tokenizer {
170 info!(t5_tok = %t5_tok.display());
171 }
172 if let Some(clip_tok) = &paths.clip_tokenizer {
173 info!(clip_tok = %clip_tok.display());
174 }
175 if let Some(clip2) = &paths.clip_encoder_2 {
176 info!(clip2 = %clip2.display());
177 }
178 if let Some(clip2_tok) = &paths.clip_tokenizer_2 {
179 info!(clip2_tok = %clip2_tok.display());
180 }
181 for (i, te) in paths.text_encoder_files.iter().enumerate() {
182 info!(text_encoder_shard = i, path = %te.display());
183 }
184 if let Some(text_tok) = &paths.text_tokenizer {
185 info!(text_tok = %text_tok.display());
186 }
187 info!("multi-GPU mode defers model loading to per-GPU workers");
188 } else {
189 info!("no default model configured — models will be pulled on first request");
190 }
191 let mut state = state::AppState::empty(config, queue_handle, gpu_pool.clone(), queue_size);
192 state.shared_pool = shared_pool;
193 state
194 } else {
195 match ModelPaths::resolve(&model_name, &config) {
196 Some(paths) => {
197 info!(model = %model_name, "configured model");
198 info!(transformer = %paths.transformer.display());
199 info!(vae = %paths.vae.display());
200 if let Some(spatial_upscaler) = &paths.spatial_upscaler {
201 info!(spatial_upscaler = %spatial_upscaler.display());
202 }
203 if let Some(t5) = &paths.t5_encoder {
204 info!(t5 = %t5.display());
205 }
206 if let Some(clip) = &paths.clip_encoder {
207 info!(clip = %clip.display());
208 }
209 if let Some(t5_tok) = &paths.t5_tokenizer {
210 info!(t5_tok = %t5_tok.display());
211 }
212 if let Some(clip_tok) = &paths.clip_tokenizer {
213 info!(clip_tok = %clip_tok.display());
214 }
215 if let Some(clip2) = &paths.clip_encoder_2 {
216 info!(clip2 = %clip2.display());
217 }
218 if let Some(clip2_tok) = &paths.clip_tokenizer_2 {
219 info!(clip2_tok = %clip2_tok.display());
220 }
221 for (i, te) in paths.text_encoder_files.iter().enumerate() {
222 info!(text_encoder_shard = i, path = %te.display());
223 }
224 if let Some(text_tok) = &paths.text_tokenizer {
225 info!(text_tok = %text_tok.display());
226 }
227
228 let offload = std::env::var("MOLD_OFFLOAD").is_ok_and(|v| v == "1");
229 let engine = mold_inference::create_engine_with_pool(
230 model_name,
231 paths,
232 &config,
233 mold_inference::LoadStrategy::Eager,
234 0,
235 offload,
236 Some(shared_pool.clone()),
237 )?;
238 let mut state = state::AppState::new(
239 engine,
240 config,
241 queue_handle,
242 gpu_pool.clone(),
243 queue_size,
244 );
245 state.shared_pool = shared_pool;
246 state
247 }
248 None => {
249 info!("no default model configured — models will be pulled on first request");
250 state::AppState::empty(config, queue_handle, gpu_pool.clone(), queue_size)
251 }
252 }
253 };
254
255 match mold_db::open_default() {
257 Ok(Some(db)) => {
258 info!(db = %db.path().display(), "metadata DB opened");
259 state.metadata_db = std::sync::Arc::new(Some(db));
260 }
261 Ok(None) => {
262 tracing::info!("metadata DB disabled (MOLD_DB_DISABLE set or MOLD_HOME unresolved)");
263 }
264 Err(e) => {
265 tracing::warn!(
266 "failed to open metadata DB: {e:#} — gallery falls back to filesystem scan"
267 );
268 }
269 }
270
271 if state.metadata_db.is_some() {
272 let Some(jobs_root) = Config::mold_dir().map(|dir| dir.join("jobs")) else {
273 anyhow::bail!("metadata DB opened but MOLD_HOME could not be resolved for chain jobs");
274 };
275 std::fs::create_dir_all(&jobs_root)?;
276 let db_arc = state.metadata_db.clone();
277 let reconcile_root = jobs_root.clone();
278 let (flipped, repaired) = tokio::task::spawn_blocking(move || {
279 let Some(db) = db_arc.as_ref().as_ref() else {
280 anyhow::bail!("metadata DB disappeared before chain reconcile");
281 };
282 chain_job_runner::startup_reconcile(db, &reconcile_root)
283 })
284 .await??;
285 tracing::info!(
286 flipped,
287 repaired,
288 jobs_root = %jobs_root.display(),
289 "chain job startup reconcile complete"
290 );
291
292 let gc_db = state.metadata_db.clone();
293 let gc_root = jobs_root.clone();
294 let startup_gc = tokio::task::spawn_blocking(move || {
295 let Some(db) = gc_db.as_ref().as_ref() else {
296 anyhow::bail!("metadata DB disappeared before chain startup GC");
297 };
298 chain_job_runner::startup_gc_sweep(db, &gc_root)
299 })
300 .await??;
301 tracing::info!(
302 swept_ephemeral_jobs = startup_gc.swept_ephemeral_jobs,
303 pruned_artifact_dirs = startup_gc.pruned_artifact_dirs,
304 jobs_root = %jobs_root.display(),
305 "chain job startup GC complete"
306 );
307
308 let config_snapshot = state.config.read().await.clone();
309 let output_dir = if config_snapshot.is_output_disabled() {
310 None
311 } else {
312 Some(config_snapshot.effective_output_dir())
313 };
314 let deps = chain_job_runner::RunnerDeps {
315 db: state.metadata_db.clone(),
316 jobs_root,
317 executor: std::sync::Arc::new(chain_job_runner::ProductionStageExecutor::new(
318 state.gpu_pool.clone(),
319 config_snapshot,
320 )),
321 queue_probe: std::sync::Arc::new(chain_job_runner::ProductionQueueProbe::new(
322 state.queue.clone(),
323 state.gpu_pool.clone(),
324 )),
325 events: std::sync::Arc::new(chain_job_runner::JobEventBus::new()),
326 cancel: std::sync::Arc::new(chain_job_runner::CancelRegistry::new()),
327 job_locks: std::sync::Arc::new(chain_job_runner::JobMutationLocks::new()),
328 claims: std::sync::Arc::new(chain_job_runner::EphemeralClaims::default()),
329 output_dir,
330 server_events: Some(state.events.clone()),
331 };
332 state.chain_jobs = Some(std::sync::Arc::new(chain_job_runner::spawn_runner(deps)));
333 }
334
335 let worker_state = state.clone();
339 if gpu_pool.worker_count() > 0 {
340 tokio::spawn(queue::run_queue_dispatcher(job_rx, worker_state));
341 } else {
342 tokio::spawn(queue::run_queue_worker(job_rx, worker_state));
343 }
344
345 let idle_evict_handle = spawn_cache_idle_evictor(
349 state.model_cache.clone(),
350 state.model_load_lock.clone(),
351 gpu_pool.clone(),
352 std::time::Duration::from_secs(state::resolve_cache_idle_ttl_secs()),
353 );
354
355 let downloads_shutdown = tokio_util::sync::CancellationToken::new();
362 let downloads_models_dir = state.config.read().await.resolved_models_dir();
363 let downloads_driver = crate::downloads::spawn_driver(
364 state.downloads.clone(),
365 std::sync::Arc::new(crate::downloads::HfPullDriver),
366 std::sync::Arc::new(crate::downloads::CivitaiRecipeDriver),
367 downloads_models_dir,
368 downloads_shutdown.clone(),
369 );
370
371 {
373 let config = state.config.read().await;
374 if config.is_output_disabled() {
375 tracing::warn!(
376 "image output is disabled (output_dir is empty) — \
377 generated images will not be saved and the TUI gallery will be empty"
378 );
379 } else {
380 let output_dir = config.effective_output_dir();
381 let _ = std::fs::create_dir_all(&output_dir);
382 info!(output_dir = %output_dir.display(), "gallery output directory");
383 routes::spawn_thumbnail_warmup(&config);
384
385 if state.metadata_db.is_some() {
389 let db_arc = state.metadata_db.clone();
390 let dir = output_dir.clone();
391 tokio::spawn(async move {
392 let join = tokio::task::spawn_blocking(move || {
393 if let Some(db) = db_arc.as_ref() {
394 db.reconcile(&dir)
395 } else {
396 Ok(mold_db::ReconcileStats::default())
397 }
398 })
399 .await;
400 match join {
401 Ok(Ok(stats)) => tracing::info!(
402 imported = stats.imported,
403 updated = stats.updated,
404 removed = stats.removed,
405 kept = stats.kept,
406 "metadata DB reconciled with gallery directory"
407 ),
408 Ok(Err(e)) => tracing::warn!("metadata DB reconcile failed: {e:#}"),
409 Err(e) => tracing::warn!("metadata DB reconcile task join error: {e}"),
410 }
411 });
412 }
413 }
414 }
415
416 let auth_state = auth::load_api_keys()?;
418 #[cfg(feature = "mdns")]
421 let mdns_auth_required = auth_state.is_some();
422 let rl_config = rate_limit::load_rate_limit_config()?;
423
424 let cors = build_cors_layer()?;
425
426 #[cfg(feature = "metrics")]
429 let prometheus_handle = metrics::install_recorder();
430
431 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
437 *state.shutdown_tx.lock().await = Some(shutdown_tx);
438
439 #[cfg(unix)]
440 {
441 let sigterm_state = state.clone();
442 tokio::spawn(async move {
443 if let Ok(mut sig) =
444 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
445 {
446 sig.recv().await;
447 tracing::info!("received SIGTERM, initiating graceful shutdown");
448 if let Some(tx) = sigterm_state.shutdown_tx.lock().await.take() {
449 let _ = tx.send(());
450 }
451 }
452 });
453 }
454
455 let resources_aggregator = resources::spawn_aggregator(state.resources.clone());
459
460 #[cfg(feature = "metrics")]
462 let server_start_time = state.start_time;
463
464 #[allow(unused_mut)]
467 let mut app = routes::create_router(state)
468 .merge(web_ui::router())
469 .layer(DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES))
470 .layer(middleware::from_fn(rate_limit::rate_limit_middleware))
471 .layer(middleware::from_fn_with_state(
472 rl_config,
473 rate_limit::inject_rate_limit_state,
474 ))
475 .layer(middleware::from_fn(auth::require_api_key))
476 .layer(middleware::from_fn_with_state(
477 auth_state,
478 auth::inject_auth_state,
479 ));
480
481 #[cfg(feature = "metrics")]
484 {
485 app = app.layer(middleware::from_fn(metrics::http_metrics_middleware));
486 }
487
488 #[cfg(feature = "metrics")]
489 {
490 let metrics_state = metrics::MetricsState {
491 handle: prometheus_handle,
492 start_time: server_start_time,
493 };
494 app = app.route(
495 "/metrics",
496 axum::routing::get(metrics::metrics_endpoint).with_state(metrics_state),
497 );
498 }
499
500 let app = app
501 .layer(middleware::from_fn(request_id::request_id_middleware))
502 .layer(TraceLayer::new_for_http())
503 .layer(cors);
504
505 let addr: SocketAddr = format!("{bind}:{port}").parse()?;
506 let version = mold_core::build_info::version_string();
507 info!(%addr, %version, "starting mold server");
508
509 let listener = TcpListener::bind(addr).await?;
510
511 #[cfg(feature = "mdns")]
517 let mdns_guard = {
518 let bound_port = listener.local_addr()?.port();
519 if mdns::enabled_from_env() && mdns::is_advertisable(bind, bound_port) {
520 let txt = mdns::build_txt_records(
521 mold_core::build_info::VERSION,
522 mold_core::build_info::GIT_SHA,
523 mdns_auth_required,
524 &mdns_gpu_summary,
525 queue_size,
526 );
527 match mdns::register(bind, bound_port, txt) {
528 Ok(guard) => Some(guard),
529 Err(e) => {
530 tracing::warn!(error = %format!("{e:#}"), "mDNS advertising disabled");
531 None
532 }
533 }
534 } else {
535 tracing::debug!("mDNS advertising skipped (disabled or loopback bind)");
536 None
537 }
538 };
539
540 axum::serve(
541 listener,
542 app.into_make_service_with_connect_info::<SocketAddr>(),
543 )
544 .with_graceful_shutdown(async {
545 let _ = shutdown_rx.await;
546 tracing::info!("shutting down");
547 })
548 .await?;
549
550 #[cfg(feature = "mdns")]
555 if let Some(guard) = mdns_guard {
556 guard.shutdown();
557 }
558 downloads_shutdown.cancel();
559 downloads_driver.abort();
560 idle_evict_handle.abort();
561 resources_aggregator.abort();
564
565 Ok(())
566}
567
568fn spawn_cache_idle_evictor(
581 legacy_cache: std::sync::Arc<tokio::sync::Mutex<model_cache::ModelCache>>,
582 legacy_load_lock: std::sync::Arc<tokio::sync::Mutex<()>>,
583 gpu_pool: std::sync::Arc<gpu_pool::GpuPool>,
584 ttl: std::time::Duration,
585) -> tokio::task::JoinHandle<()> {
586 use tokio::time::{interval, MissedTickBehavior};
587 tokio::spawn(async move {
588 let mut tick = interval(std::time::Duration::from_secs(60));
589 tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
590 tick.tick().await;
593 loop {
594 tick.tick().await;
595
596 {
603 let _load_guard = legacy_load_lock.lock().await;
604 let evicted = {
605 let mut cache = legacy_cache.lock().await;
606 cache.evict_idle(ttl)
607 };
608 let evicted_count = evicted.len();
609 drop(evicted);
612
613 let legacy_active = legacy_cache.lock().await.active_model().is_some();
619 if evicted_count > 0 && !legacy_active {
620 tokio::task::spawn_blocking(|| {
621 mold_inference::reclaim_gpu_memory(0);
622 })
623 .await
624 .ok();
625 }
626 }
627
628 for worker in &gpu_pool.workers {
634 let worker = worker.clone();
635 let ttl = ttl;
636 tokio::task::spawn_blocking(move || {
637 let _load_guard = match worker.model_load_lock.lock() {
638 Ok(g) => g,
639 Err(poisoned) => poisoned.into_inner(),
640 };
641 let evicted = {
642 let mut cache =
643 worker.model_cache.lock().unwrap_or_else(|e| e.into_inner());
644 cache.evict_idle(ttl)
645 };
646 let evicted_count = evicted.len();
647 drop(evicted);
648
649 let active = worker
650 .model_cache
651 .lock()
652 .map(|c| c.active_model().is_some())
653 .unwrap_or(true);
654 if evicted_count > 0 && !active {
655 mold_inference::device::init_thread_gpu_ordinal(worker.gpu.ordinal);
661 mold_inference::reclaim_gpu_memory(worker.gpu.ordinal);
662 mold_inference::device::clear_thread_gpu_ordinal();
663 }
664 })
665 .await
666 .ok();
667 }
668 }
669 })
670}
671
672fn build_cors_layer() -> Result<CorsLayer> {
673 let cors = match std::env::var("MOLD_CORS_ORIGIN") {
674 Ok(origin) if !origin.is_empty() => {
675 let origin = origin
676 .parse::<axum::http::HeaderValue>()
677 .map_err(|_| anyhow::anyhow!("invalid MOLD_CORS_ORIGIN value: {origin}"))?;
678 CorsLayer::new()
679 .allow_origin(origin)
680 .allow_methods([
681 axum::http::Method::GET,
682 axum::http::Method::POST,
683 axum::http::Method::DELETE,
684 ])
685 .allow_headers(tower_http::cors::Any)
686 .expose_headers([
687 axum::http::header::HeaderName::from_static("x-mold-seed-used"),
688 axum::http::header::HeaderName::from_static("x-request-id"),
689 axum::http::header::HeaderName::from_static("retry-after"),
690 axum::http::header::HeaderName::from_static("x-mold-video-frames"),
691 axum::http::header::HeaderName::from_static("x-mold-video-fps"),
692 axum::http::header::HeaderName::from_static("x-mold-video-width"),
693 axum::http::header::HeaderName::from_static("x-mold-video-height"),
694 axum::http::header::HeaderName::from_static("x-mold-video-has-audio"),
695 axum::http::header::HeaderName::from_static("x-mold-video-duration-ms"),
696 axum::http::header::HeaderName::from_static("x-mold-video-audio-sample-rate"),
697 axum::http::header::HeaderName::from_static("x-mold-video-audio-channels"),
698 axum::http::header::HeaderName::from_static("x-mold-dimension-warning"),
699 ])
700 }
701 _ => CorsLayer::permissive(),
702 };
703 Ok(cors)
704}
705
706#[cfg(test)]
707mod tests {
708 use super::build_cors_layer;
709 use std::sync::Mutex;
710
711 static ENV_LOCK: Mutex<()> = Mutex::new(());
712
713 #[test]
714 fn invalid_cors_origin_returns_error() {
715 let _lock = ENV_LOCK.lock().unwrap();
716 std::env::set_var("MOLD_CORS_ORIGIN", "\nnot-a-header");
717 let result = build_cors_layer();
718 std::env::remove_var("MOLD_CORS_ORIGIN");
719 assert!(result.is_err());
720 }
721
722 #[test]
723 fn valid_cors_origin_builds_layer() {
724 let _lock = ENV_LOCK.lock().unwrap();
725 std::env::set_var("MOLD_CORS_ORIGIN", "https://example.com");
726 let result = build_cors_layer();
727 std::env::remove_var("MOLD_CORS_ORIGIN");
728 assert!(result.is_ok());
729 }
730}