1pub mod auth;
2pub mod logging;
3#[cfg(feature = "metrics")]
4pub mod metrics;
5pub mod model_cache;
6pub mod model_manager;
7pub mod queue;
8pub mod rate_limit;
9pub mod request_id;
10pub mod routes;
11pub mod state;
12pub mod web_ui;
13
14#[cfg(all(test, feature = "metrics"))]
15mod metrics_test;
16#[cfg(test)]
17mod routes_test;
18
19use anyhow::Result;
20use axum::{extract::DefaultBodyLimit, middleware};
21use mold_core::{Config, ModelPaths};
22use std::net::SocketAddr;
23use std::path::PathBuf;
24use tokio::net::TcpListener;
25use tower_http::cors::CorsLayer;
26use tower_http::trace::TraceLayer;
27use tracing::info;
28
29use state::QueueHandle;
30
31const MAX_REQUEST_BODY_BYTES: usize = 64 * 1024 * 1024;
32
33pub async fn run_server(bind: &str, port: u16, models_dir: PathBuf) -> Result<()> {
34 Config::install_runtime_models_dir_override(models_dir.clone());
35
36 let mut config = Config::load_or_default();
37 config.models_dir = models_dir.to_string_lossy().into_owned();
38 let model_name = config.resolved_default_model();
39
40 let (job_tx, job_rx) = tokio::sync::mpsc::channel(16);
42 let queue_handle = QueueHandle::new(job_tx);
43
44 let state = match ModelPaths::resolve(&model_name, &config) {
45 Some(paths) => {
46 info!(model = %model_name, "configured model");
47 info!(transformer = %paths.transformer.display());
48 info!(vae = %paths.vae.display());
49 if let Some(spatial_upscaler) = &paths.spatial_upscaler {
50 info!(spatial_upscaler = %spatial_upscaler.display());
51 }
52 if let Some(t5) = &paths.t5_encoder {
53 info!(t5 = %t5.display());
54 }
55 if let Some(clip) = &paths.clip_encoder {
56 info!(clip = %clip.display());
57 }
58 if let Some(t5_tok) = &paths.t5_tokenizer {
59 info!(t5_tok = %t5_tok.display());
60 }
61 if let Some(clip_tok) = &paths.clip_tokenizer {
62 info!(clip_tok = %clip_tok.display());
63 }
64 if let Some(clip2) = &paths.clip_encoder_2 {
65 info!(clip2 = %clip2.display());
66 }
67 if let Some(clip2_tok) = &paths.clip_tokenizer_2 {
68 info!(clip2_tok = %clip2_tok.display());
69 }
70 for (i, te) in paths.text_encoder_files.iter().enumerate() {
71 info!(text_encoder_shard = i, path = %te.display());
72 }
73 if let Some(text_tok) = &paths.text_tokenizer {
74 info!(text_tok = %text_tok.display());
75 }
76
77 let offload = std::env::var("MOLD_OFFLOAD").is_ok_and(|v| v == "1");
78 let shared_pool = std::sync::Arc::new(std::sync::Mutex::new(
80 mold_inference::shared_pool::SharedPool::new(),
81 ));
82 let engine = mold_inference::create_engine_with_pool(
83 model_name,
84 paths,
85 &config,
86 mold_inference::LoadStrategy::Eager,
87 offload,
88 Some(shared_pool.clone()),
89 )?;
90 let mut state = state::AppState::new(engine, config, queue_handle);
91 state.shared_pool = shared_pool;
92 state
93 }
94 None => {
95 info!("no default model configured — models will be pulled on first request");
96 state::AppState::empty(config, queue_handle)
97 }
98 };
99
100 let worker_state = state.clone();
102 tokio::spawn(queue::run_queue_worker(job_rx, worker_state));
103
104 {
106 let config = state.config.read().await;
107 if config.is_output_disabled() {
108 tracing::warn!(
109 "image output is disabled (output_dir is empty) — \
110 generated images will not be saved and the TUI gallery will be empty"
111 );
112 } else {
113 let output_dir = config.effective_output_dir();
114 let _ = std::fs::create_dir_all(&output_dir);
115 info!(output_dir = %output_dir.display(), "gallery output directory");
116 routes::spawn_thumbnail_warmup(&config);
117 }
118 }
119
120 let auth_state = auth::load_api_keys()?;
122 let rl_config = rate_limit::load_rate_limit_config()?;
123
124 let cors = build_cors_layer()?;
125
126 #[cfg(feature = "metrics")]
129 let prometheus_handle = metrics::install_recorder();
130
131 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
137 *state.shutdown_tx.lock().await = Some(shutdown_tx);
138
139 #[cfg(unix)]
140 {
141 let sigterm_state = state.clone();
142 tokio::spawn(async move {
143 if let Ok(mut sig) =
144 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
145 {
146 sig.recv().await;
147 tracing::info!("received SIGTERM, initiating graceful shutdown");
148 if let Some(tx) = sigterm_state.shutdown_tx.lock().await.take() {
149 let _ = tx.send(());
150 }
151 }
152 });
153 }
154
155 #[cfg(feature = "metrics")]
157 let server_start_time = state.start_time;
158
159 #[allow(unused_mut)]
162 let mut app = routes::create_router(state)
163 .merge(web_ui::router())
164 .layer(DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES))
165 .layer(middleware::from_fn(rate_limit::rate_limit_middleware))
166 .layer(middleware::from_fn_with_state(
167 rl_config,
168 rate_limit::inject_rate_limit_state,
169 ))
170 .layer(middleware::from_fn(auth::require_api_key))
171 .layer(middleware::from_fn_with_state(
172 auth_state,
173 auth::inject_auth_state,
174 ));
175
176 #[cfg(feature = "metrics")]
179 {
180 app = app.layer(middleware::from_fn(metrics::http_metrics_middleware));
181 }
182
183 #[cfg(feature = "metrics")]
184 {
185 let metrics_state = metrics::MetricsState {
186 handle: prometheus_handle,
187 start_time: server_start_time,
188 };
189 app = app.route(
190 "/metrics",
191 axum::routing::get(metrics::metrics_endpoint).with_state(metrics_state),
192 );
193 }
194
195 let app = app
196 .layer(middleware::from_fn(request_id::request_id_middleware))
197 .layer(TraceLayer::new_for_http())
198 .layer(cors);
199
200 let addr: SocketAddr = format!("{bind}:{port}").parse()?;
201 let version = mold_core::build_info::version_string();
202 info!(%addr, %version, "starting mold server");
203
204 let listener = TcpListener::bind(addr).await?;
205 axum::serve(
206 listener,
207 app.into_make_service_with_connect_info::<SocketAddr>(),
208 )
209 .with_graceful_shutdown(async {
210 let _ = shutdown_rx.await;
211 tracing::info!("shutting down");
212 })
213 .await?;
214
215 Ok(())
216}
217
218fn build_cors_layer() -> Result<CorsLayer> {
219 let cors = match std::env::var("MOLD_CORS_ORIGIN") {
220 Ok(origin) if !origin.is_empty() => {
221 let origin = origin
222 .parse::<axum::http::HeaderValue>()
223 .map_err(|_| anyhow::anyhow!("invalid MOLD_CORS_ORIGIN value: {origin}"))?;
224 CorsLayer::new()
225 .allow_origin(origin)
226 .allow_methods([
227 axum::http::Method::GET,
228 axum::http::Method::POST,
229 axum::http::Method::DELETE,
230 ])
231 .allow_headers(tower_http::cors::Any)
232 .expose_headers([
233 axum::http::header::HeaderName::from_static("x-mold-seed-used"),
234 axum::http::header::HeaderName::from_static("x-request-id"),
235 axum::http::header::HeaderName::from_static("retry-after"),
236 axum::http::header::HeaderName::from_static("x-mold-video-frames"),
237 axum::http::header::HeaderName::from_static("x-mold-video-fps"),
238 axum::http::header::HeaderName::from_static("x-mold-video-width"),
239 axum::http::header::HeaderName::from_static("x-mold-video-height"),
240 axum::http::header::HeaderName::from_static("x-mold-video-has-audio"),
241 axum::http::header::HeaderName::from_static("x-mold-video-duration-ms"),
242 axum::http::header::HeaderName::from_static("x-mold-video-audio-sample-rate"),
243 axum::http::header::HeaderName::from_static("x-mold-video-audio-channels"),
244 axum::http::header::HeaderName::from_static("x-mold-dimension-warning"),
245 ])
246 }
247 _ => CorsLayer::permissive(),
248 };
249 Ok(cors)
250}
251
252#[cfg(test)]
253mod tests {
254 use super::build_cors_layer;
255 use std::sync::Mutex;
256
257 static ENV_LOCK: Mutex<()> = Mutex::new(());
258
259 #[test]
260 fn invalid_cors_origin_returns_error() {
261 let _lock = ENV_LOCK.lock().unwrap();
262 std::env::set_var("MOLD_CORS_ORIGIN", "\nnot-a-header");
263 let result = build_cors_layer();
264 std::env::remove_var("MOLD_CORS_ORIGIN");
265 assert!(result.is_err());
266 }
267
268 #[test]
269 fn valid_cors_origin_builds_layer() {
270 let _lock = ENV_LOCK.lock().unwrap();
271 std::env::set_var("MOLD_CORS_ORIGIN", "https://example.com");
272 let result = build_cors_layer();
273 std::env::remove_var("MOLD_CORS_ORIGIN");
274 assert!(result.is_ok());
275 }
276}