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