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