Skip to main content

mold_server/
lib.rs

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;
12
13#[cfg(all(test, feature = "metrics"))]
14mod metrics_test;
15#[cfg(test)]
16mod routes_test;
17
18use anyhow::Result;
19use axum::middleware;
20use mold_core::{Config, ModelPaths};
21use std::net::SocketAddr;
22use std::path::PathBuf;
23use tokio::net::TcpListener;
24use tower_http::cors::CorsLayer;
25use tower_http::trace::TraceLayer;
26use tracing::info;
27
28use state::QueueHandle;
29
30pub async fn run_server(bind: &str, port: u16, models_dir: PathBuf) -> Result<()> {
31    Config::install_runtime_models_dir_override(models_dir.clone());
32
33    let mut config = Config::load_or_default();
34    config.models_dir = models_dir.to_string_lossy().into_owned();
35    let model_name = config.resolved_default_model();
36
37    // Create the generation queue channel (bounded, 16 slots).
38    let (job_tx, job_rx) = tokio::sync::mpsc::channel(16);
39    let queue_handle = QueueHandle::new(job_tx);
40
41    let state = match ModelPaths::resolve(&model_name, &config) {
42        Some(paths) => {
43            info!(model = %model_name, "configured model");
44            info!(transformer = %paths.transformer.display());
45            info!(vae = %paths.vae.display());
46            if let Some(spatial_upscaler) = &paths.spatial_upscaler {
47                info!(spatial_upscaler = %spatial_upscaler.display());
48            }
49            if let Some(t5) = &paths.t5_encoder {
50                info!(t5 = %t5.display());
51            }
52            if let Some(clip) = &paths.clip_encoder {
53                info!(clip = %clip.display());
54            }
55            if let Some(t5_tok) = &paths.t5_tokenizer {
56                info!(t5_tok = %t5_tok.display());
57            }
58            if let Some(clip_tok) = &paths.clip_tokenizer {
59                info!(clip_tok = %clip_tok.display());
60            }
61            if let Some(clip2) = &paths.clip_encoder_2 {
62                info!(clip2 = %clip2.display());
63            }
64            if let Some(clip2_tok) = &paths.clip_tokenizer_2 {
65                info!(clip2_tok = %clip2_tok.display());
66            }
67            for (i, te) in paths.text_encoder_files.iter().enumerate() {
68                info!(text_encoder_shard = i, path = %te.display());
69            }
70            if let Some(text_tok) = &paths.text_tokenizer {
71                info!(text_tok = %text_tok.display());
72            }
73
74            let offload = std::env::var("MOLD_OFFLOAD").is_ok_and(|v| v == "1");
75            // Create shared pool before engine so the first engine can populate it.
76            let shared_pool = std::sync::Arc::new(std::sync::Mutex::new(
77                mold_inference::shared_pool::SharedPool::new(),
78            ));
79            let engine = mold_inference::create_engine_with_pool(
80                model_name,
81                paths,
82                &config,
83                mold_inference::LoadStrategy::Eager,
84                offload,
85                Some(shared_pool.clone()),
86            )?;
87            let mut state = state::AppState::new(engine, config, queue_handle);
88            state.shared_pool = shared_pool;
89            state
90        }
91        None => {
92            info!("no default model configured — models will be pulled on first request");
93            state::AppState::empty(config, queue_handle)
94        }
95    };
96
97    // Spawn the generation queue worker — processes jobs sequentially (single GPU).
98    let worker_state = state.clone();
99    tokio::spawn(queue::run_queue_worker(job_rx, worker_state));
100
101    // Ensure output directory exists and pre-generate thumbnails.
102    {
103        let config = state.config.read().await;
104        if config.is_output_disabled() {
105            tracing::warn!(
106                "image output is disabled (output_dir is empty) — \
107                 generated images will not be saved and the TUI gallery will be empty"
108            );
109        } else {
110            let output_dir = config.effective_output_dir();
111            let _ = std::fs::create_dir_all(&output_dir);
112            info!(output_dir = %output_dir.display(), "gallery output directory");
113            routes::spawn_thumbnail_warmup(&config);
114        }
115    }
116
117    // Load optional auth and rate-limit configuration from env vars.
118    let auth_state = auth::load_api_keys()?;
119    let rl_config = rate_limit::load_rate_limit_config()?;
120
121    let cors = build_cors_layer()?;
122
123    // Install the Prometheus metrics recorder (when feature-enabled).
124    // Must happen before any middleware or handler that records metrics.
125    #[cfg(feature = "metrics")]
126    let prometheus_handle = metrics::install_recorder();
127
128    // Build the router with middleware layers.
129    // Order (outermost → innermost): CORS → Trace → RequestID → Metrics → Auth → RateLimit → routes
130    // All inject + enforce layers use .layer() (not .route_layer()) so they run on
131    // ALL requests, including unmatched 404 paths — preventing auth/rate-limit bypass.
132    // Set up graceful shutdown: fires on SIGTERM or POST /api/shutdown.
133    let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
134    *state.shutdown_tx.lock().await = Some(shutdown_tx);
135
136    #[cfg(unix)]
137    {
138        let sigterm_state = state.clone();
139        tokio::spawn(async move {
140            if let Ok(mut sig) =
141                tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
142            {
143                sig.recv().await;
144                tracing::info!("received SIGTERM, initiating graceful shutdown");
145                if let Some(tx) = sigterm_state.shutdown_tx.lock().await.take() {
146                    let _ = tx.send(());
147                }
148            }
149        });
150    }
151
152    // Save start_time before state is moved into the router (needed for metrics).
153    #[cfg(feature = "metrics")]
154    let server_start_time = state.start_time;
155
156    // The /metrics endpoint is mounted outside the auth/rate-limit stack so it
157    // is always accessible for monitoring scrapers (Prometheus, Grafana Agent, etc.).
158    #[allow(unused_mut)]
159    let mut app = routes::create_router(state)
160        .layer(middleware::from_fn(rate_limit::rate_limit_middleware))
161        .layer(middleware::from_fn_with_state(
162            rl_config,
163            rate_limit::inject_rate_limit_state,
164        ))
165        .layer(middleware::from_fn(auth::require_api_key))
166        .layer(middleware::from_fn_with_state(
167            auth_state,
168            auth::inject_auth_state,
169        ));
170
171    // HTTP metrics middleware sits outside auth so it observes all requests
172    // (including auth failures and rate-limited responses).
173    #[cfg(feature = "metrics")]
174    {
175        app = app.layer(middleware::from_fn(metrics::http_metrics_middleware));
176    }
177
178    #[cfg(feature = "metrics")]
179    {
180        let metrics_state = metrics::MetricsState {
181            handle: prometheus_handle,
182            start_time: server_start_time,
183        };
184        app = app.route(
185            "/metrics",
186            axum::routing::get(metrics::metrics_endpoint).with_state(metrics_state),
187        );
188    }
189
190    let app = app
191        .layer(middleware::from_fn(request_id::request_id_middleware))
192        .layer(TraceLayer::new_for_http())
193        .layer(cors);
194
195    let addr: SocketAddr = format!("{bind}:{port}").parse()?;
196    let version = mold_core::build_info::version_string();
197    info!(%addr, %version, "starting mold server");
198
199    let listener = TcpListener::bind(addr).await?;
200    axum::serve(
201        listener,
202        app.into_make_service_with_connect_info::<SocketAddr>(),
203    )
204    .with_graceful_shutdown(async {
205        let _ = shutdown_rx.await;
206        tracing::info!("shutting down");
207    })
208    .await?;
209
210    Ok(())
211}
212
213fn build_cors_layer() -> Result<CorsLayer> {
214    let cors = match std::env::var("MOLD_CORS_ORIGIN") {
215        Ok(origin) if !origin.is_empty() => {
216            let origin = origin
217                .parse::<axum::http::HeaderValue>()
218                .map_err(|_| anyhow::anyhow!("invalid MOLD_CORS_ORIGIN value: {origin}"))?;
219            CorsLayer::new()
220                .allow_origin(origin)
221                .allow_methods([
222                    axum::http::Method::GET,
223                    axum::http::Method::POST,
224                    axum::http::Method::DELETE,
225                ])
226                .allow_headers(tower_http::cors::Any)
227                .expose_headers([
228                    axum::http::header::HeaderName::from_static("x-mold-seed-used"),
229                    axum::http::header::HeaderName::from_static("x-request-id"),
230                    axum::http::header::HeaderName::from_static("retry-after"),
231                ])
232        }
233        _ => CorsLayer::permissive(),
234    };
235    Ok(cors)
236}
237
238#[cfg(test)]
239mod tests {
240    use super::build_cors_layer;
241    use std::sync::Mutex;
242
243    static ENV_LOCK: Mutex<()> = Mutex::new(());
244
245    #[test]
246    fn invalid_cors_origin_returns_error() {
247        let _lock = ENV_LOCK.lock().unwrap();
248        std::env::set_var("MOLD_CORS_ORIGIN", "\nnot-a-header");
249        let result = build_cors_layer();
250        std::env::remove_var("MOLD_CORS_ORIGIN");
251        assert!(result.is_err());
252    }
253
254    #[test]
255    fn valid_cors_origin_builds_layer() {
256        let _lock = ENV_LOCK.lock().unwrap();
257        std::env::set_var("MOLD_CORS_ORIGIN", "https://example.com");
258        let result = build_cors_layer();
259        std::env::remove_var("MOLD_CORS_ORIGIN");
260        assert!(result.is_ok());
261    }
262}