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::{AtomicBool, 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
56fn trace_request_path<B>(request: &axum::http::Request<B>) -> &str {
57 request.uri().path()
60}
61
62pub async fn run_server(
63 bind: &str,
64 port: u16,
65 models_dir: PathBuf,
66 gpu_selection: GpuSelection,
67 queue_size: usize,
68) -> Result<()> {
69 signals::ignore_sigpipe();
75
76 Config::install_runtime_models_dir_override(models_dir.clone());
77
78 let mut config = Config::load_or_default();
79 config.models_dir = models_dir.to_string_lossy().into_owned();
80 let model_name = config.resolved_default_model();
81
82 let shared_pool = std::sync::Arc::new(std::sync::Mutex::new(
84 mold_inference::shared_pool::SharedPool::new(),
85 ));
86 let fatal_cuda_error = std::sync::Arc::new(AtomicBool::new(false));
90 let fatal_cuda_shutdown = std::sync::Arc::new(tokio::sync::Notify::new());
91
92 let discovered = mold_inference::device::discover_gpus();
93 let selected = mold_inference::device::filter_gpus(&discovered, &gpu_selection);
94
95 if selected.is_empty() && !discovered.is_empty() {
96 anyhow::bail!(
97 "No GPUs matched selection {:?} (discovered: {:?})",
98 gpu_selection,
99 discovered.iter().map(|g| g.ordinal).collect::<Vec<_>>()
100 );
101 }
102
103 let mut workers = Vec::new();
104 let mut _gpu_thread_handles = Vec::new();
105
106 const PER_WORKER_CHANNEL_SIZE: usize = 2;
111
112 let max_cached = state::resolve_max_cached_models();
113 for gpu in &selected {
114 let (job_tx, job_rx) = std::sync::mpsc::sync_channel(PER_WORKER_CHANNEL_SIZE);
115 let worker = std::sync::Arc::new(gpu_pool::GpuWorker {
116 gpu: gpu.clone(),
117 model_cache: std::sync::Arc::new(std::sync::Mutex::new(model_cache::ModelCache::new(
118 max_cached,
119 ))),
120 active_generation: std::sync::Arc::new(std::sync::RwLock::new(None)),
121 model_load_lock: std::sync::Arc::new(std::sync::Mutex::new(())),
122 shared_pool: shared_pool.clone(),
123 in_flight: AtomicUsize::new(0),
124 consecutive_failures: AtomicUsize::new(0),
125 poisoned: AtomicBool::new(false),
126 fatal_cuda_error: fatal_cuda_error.clone(),
127 fatal_cuda_shutdown: fatal_cuda_shutdown.clone(),
128 degraded_until: std::sync::RwLock::new(None),
129 job_tx,
130 });
131
132 let handle = gpu_worker::spawn_gpu_thread(worker.clone(), job_rx);
133 _gpu_thread_handles.push(handle);
134 workers.push(worker);
135 }
136
137 let gpu_pool = std::sync::Arc::new(gpu_pool::GpuPool { workers });
138
139 for status in gpu_pool.gpu_status() {
141 info!(
142 gpu = status.ordinal,
143 name = %status.name,
144 vram_mb = status.vram_total_bytes / 1_000_000,
145 "GPU worker ready"
146 );
147 }
148
149 if selected.is_empty() {
150 info!("no GPUs discovered — server will operate in CPU/pull-only mode");
151 }
152
153 #[cfg(feature = "mdns")]
156 let mdns_gpu_summary = {
157 let names: Vec<String> = gpu_pool
158 .gpu_status()
159 .iter()
160 .map(|s| s.name.clone())
161 .collect();
162 mdns::gpu_summary(&names)
163 };
164
165 let (job_tx, job_rx) = tokio::sync::mpsc::channel(queue_size.max(1));
167 let queue_handle = QueueHandle::new(job_tx);
168
169 let mut state = if gpu_pool.worker_count() > 0 {
171 if let Some(paths) = ModelPaths::resolve(&model_name, &config) {
172 info!(model = %model_name, "configured model");
173 info!(transformer = %paths.transformer.display());
174 info!(vae = %paths.vae.display());
175 if let Some(spatial_upscaler) = &paths.spatial_upscaler {
176 info!(spatial_upscaler = %spatial_upscaler.display());
177 }
178 if let Some(t5) = &paths.t5_encoder {
179 info!(t5 = %t5.display());
180 }
181 if let Some(clip) = &paths.clip_encoder {
182 info!(clip = %clip.display());
183 }
184 if let Some(t5_tok) = &paths.t5_tokenizer {
185 info!(t5_tok = %t5_tok.display());
186 }
187 if let Some(clip_tok) = &paths.clip_tokenizer {
188 info!(clip_tok = %clip_tok.display());
189 }
190 if let Some(clip2) = &paths.clip_encoder_2 {
191 info!(clip2 = %clip2.display());
192 }
193 if let Some(clip2_tok) = &paths.clip_tokenizer_2 {
194 info!(clip2_tok = %clip2_tok.display());
195 }
196 for (i, te) in paths.text_encoder_files.iter().enumerate() {
197 info!(text_encoder_shard = i, path = %te.display());
198 }
199 if let Some(text_tok) = &paths.text_tokenizer {
200 info!(text_tok = %text_tok.display());
201 }
202 info!("multi-GPU mode defers model loading to per-GPU workers");
203 } else {
204 info!("no default model configured — models will be pulled on first request");
205 }
206 let mut state = state::AppState::empty(config, queue_handle, gpu_pool.clone(), queue_size);
207 state.shared_pool = shared_pool;
208 state
209 } else {
210 match ModelPaths::resolve(&model_name, &config) {
211 Some(paths) => {
212 info!(model = %model_name, "configured model");
213 info!(transformer = %paths.transformer.display());
214 info!(vae = %paths.vae.display());
215 if let Some(spatial_upscaler) = &paths.spatial_upscaler {
216 info!(spatial_upscaler = %spatial_upscaler.display());
217 }
218 if let Some(t5) = &paths.t5_encoder {
219 info!(t5 = %t5.display());
220 }
221 if let Some(clip) = &paths.clip_encoder {
222 info!(clip = %clip.display());
223 }
224 if let Some(t5_tok) = &paths.t5_tokenizer {
225 info!(t5_tok = %t5_tok.display());
226 }
227 if let Some(clip_tok) = &paths.clip_tokenizer {
228 info!(clip_tok = %clip_tok.display());
229 }
230 if let Some(clip2) = &paths.clip_encoder_2 {
231 info!(clip2 = %clip2.display());
232 }
233 if let Some(clip2_tok) = &paths.clip_tokenizer_2 {
234 info!(clip2_tok = %clip2_tok.display());
235 }
236 for (i, te) in paths.text_encoder_files.iter().enumerate() {
237 info!(text_encoder_shard = i, path = %te.display());
238 }
239 if let Some(text_tok) = &paths.text_tokenizer {
240 info!(text_tok = %text_tok.display());
241 }
242
243 let offload = std::env::var("MOLD_OFFLOAD").is_ok_and(|v| v == "1");
244 let engine = mold_inference::create_engine_with_pool(
245 model_name,
246 paths,
247 &config,
248 mold_inference::LoadStrategy::Eager,
249 0,
250 offload,
251 Some(shared_pool.clone()),
252 )?;
253 let mut state = state::AppState::new(
254 engine,
255 config,
256 queue_handle,
257 gpu_pool.clone(),
258 queue_size,
259 );
260 state.shared_pool = shared_pool;
261 state
262 }
263 None => {
264 info!("no default model configured — models will be pulled on first request");
265 state::AppState::empty(config, queue_handle, gpu_pool.clone(), queue_size)
266 }
267 }
268 };
269
270 match mold_db::open_default() {
272 Ok(Some(db)) => {
273 info!(db = %db.path().display(), "metadata DB opened");
274 state.metadata_db = std::sync::Arc::new(Some(db));
275 }
276 Ok(None) => {
277 tracing::info!("metadata DB disabled (MOLD_DB_DISABLE set or MOLD_HOME unresolved)");
278 }
279 Err(e) => {
280 tracing::warn!(
281 "failed to open metadata DB: {e:#} — gallery falls back to filesystem scan"
282 );
283 }
284 }
285
286 state.instance_id = std::sync::Arc::new(instance::resolve_instance_id(
294 state.metadata_db.as_ref().as_ref(),
295 port,
296 ));
297 #[cfg(feature = "mdns")]
298 let mdns_instance_id = state.instance_id.clone();
299
300 if state.metadata_db.is_some() {
301 let Some(jobs_root) = Config::mold_dir().map(|dir| dir.join("jobs")) else {
302 anyhow::bail!("metadata DB opened but MOLD_HOME could not be resolved for chain jobs");
303 };
304 std::fs::create_dir_all(&jobs_root)?;
305 let db_arc = state.metadata_db.clone();
306 let reconcile_root = jobs_root.clone();
307 let (flipped, repaired) = tokio::task::spawn_blocking(move || {
308 let Some(db) = db_arc.as_ref().as_ref() else {
309 anyhow::bail!("metadata DB disappeared before chain reconcile");
310 };
311 chain_job_runner::startup_reconcile(db, &reconcile_root)
312 })
313 .await??;
314 tracing::info!(
315 flipped,
316 repaired,
317 jobs_root = %jobs_root.display(),
318 "chain job startup reconcile complete"
319 );
320
321 let gc_db = state.metadata_db.clone();
322 let gc_root = jobs_root.clone();
323 let startup_gc = tokio::task::spawn_blocking(move || {
324 let Some(db) = gc_db.as_ref().as_ref() else {
325 anyhow::bail!("metadata DB disappeared before chain startup GC");
326 };
327 chain_job_runner::startup_gc_sweep(db, &gc_root)
328 })
329 .await??;
330 tracing::info!(
331 swept_ephemeral_jobs = startup_gc.swept_ephemeral_jobs,
332 pruned_artifact_dirs = startup_gc.pruned_artifact_dirs,
333 jobs_root = %jobs_root.display(),
334 "chain job startup GC complete"
335 );
336
337 let config_snapshot = state.config.read().await.clone();
338 let output_dir = if config_snapshot.is_output_disabled() {
339 None
340 } else {
341 Some(config_snapshot.effective_output_dir())
342 };
343 let deps = chain_job_runner::RunnerDeps {
344 db: state.metadata_db.clone(),
345 jobs_root,
346 executor: std::sync::Arc::new(chain_job_runner::ProductionStageExecutor::new(
347 state.gpu_pool.clone(),
348 config_snapshot,
349 )),
350 queue_probe: std::sync::Arc::new(chain_job_runner::ProductionQueueProbe::new(
351 state.queue.clone(),
352 state.gpu_pool.clone(),
353 )),
354 events: std::sync::Arc::new(chain_job_runner::JobEventBus::new()),
355 cancel: std::sync::Arc::new(chain_job_runner::CancelRegistry::new()),
356 job_locks: std::sync::Arc::new(chain_job_runner::JobMutationLocks::new()),
357 claims: std::sync::Arc::new(chain_job_runner::EphemeralClaims::default()),
358 output_dir,
359 server_events: Some(state.events.clone()),
360 };
361 state.chain_jobs = Some(std::sync::Arc::new(chain_job_runner::spawn_runner(deps)));
362 }
363
364 let worker_state = state.clone();
368 if gpu_pool.worker_count() > 0 {
369 tokio::spawn(queue::run_queue_dispatcher(job_rx, worker_state));
370 } else {
371 tokio::spawn(queue::run_queue_worker(job_rx, worker_state));
372 }
373
374 let idle_evict_handle = spawn_cache_idle_evictor(
378 state.model_cache.clone(),
379 state.model_load_lock.clone(),
380 gpu_pool.clone(),
381 std::time::Duration::from_secs(state::resolve_cache_idle_ttl_secs()),
382 );
383
384 let downloads_shutdown = tokio_util::sync::CancellationToken::new();
391 let downloads_models_dir = state.config.read().await.resolved_models_dir();
392 let downloads_driver = crate::downloads::spawn_driver(
393 state.downloads.clone(),
394 std::sync::Arc::new(crate::downloads::HfPullDriver),
395 std::sync::Arc::new(crate::downloads::CivitaiRecipeDriver),
396 downloads_models_dir,
397 downloads_shutdown.clone(),
398 );
399
400 {
402 let config = state.config.read().await;
403 if config.is_output_disabled() {
404 tracing::warn!(
405 "image output is disabled (output_dir is empty) — \
406 generated images will not be saved and the TUI gallery will be empty"
407 );
408 } else {
409 let output_dir = config.effective_output_dir();
410 let _ = std::fs::create_dir_all(&output_dir);
411 info!(output_dir = %output_dir.display(), "gallery output directory");
412 routes::spawn_thumbnail_warmup(&config);
413
414 if state.metadata_db.is_some() {
418 let db_arc = state.metadata_db.clone();
419 let dir = output_dir.clone();
420 tokio::spawn(async move {
421 let join = tokio::task::spawn_blocking(move || {
422 if let Some(db) = db_arc.as_ref() {
423 db.reconcile(&dir)
424 } else {
425 Ok(mold_db::ReconcileStats::default())
426 }
427 })
428 .await;
429 match join {
430 Ok(Ok(stats)) => tracing::info!(
431 imported = stats.imported,
432 updated = stats.updated,
433 removed = stats.removed,
434 kept = stats.kept,
435 "metadata DB reconciled with gallery directory"
436 ),
437 Ok(Err(e)) => tracing::warn!("metadata DB reconcile failed: {e:#}"),
438 Err(e) => tracing::warn!("metadata DB reconcile task join error: {e}"),
439 }
440 });
441 }
442 }
443 }
444
445 let auth_state = auth::load_api_keys()?;
447 #[cfg(feature = "mdns")]
450 let mdns_auth_required = auth_state.is_some();
451 let rl_config = rate_limit::load_rate_limit_config()?;
452
453 let cors = build_cors_layer()?;
454
455 #[cfg(feature = "metrics")]
458 let prometheus_handle = metrics::install_recorder();
459
460 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
466 *state.shutdown_tx.lock().await = Some(shutdown_tx);
467
468 #[cfg(unix)]
469 {
470 let sigterm_state = state.clone();
471 tokio::spawn(async move {
472 if let Ok(mut sig) =
473 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
474 {
475 sig.recv().await;
476 tracing::info!("received SIGTERM, initiating graceful shutdown");
477 if let Some(tx) = sigterm_state.shutdown_tx.lock().await.take() {
478 let _ = tx.send(());
479 }
480 }
481 });
482 }
483
484 let resources_aggregator = resources::spawn_aggregator(state.resources.clone());
488
489 #[cfg(feature = "metrics")]
491 let server_start_time = state.start_time;
492
493 #[allow(unused_mut)]
496 let mut app = routes::create_router(state)
497 .merge(web_ui::router())
498 .layer(DefaultBodyLimit::max(MAX_REQUEST_BODY_BYTES))
499 .layer(middleware::from_fn(rate_limit::rate_limit_middleware))
500 .layer(middleware::from_fn_with_state(
501 rl_config,
502 rate_limit::inject_rate_limit_state,
503 ))
504 .layer(middleware::from_fn(auth::require_api_key))
505 .layer(middleware::from_fn_with_state(
506 auth_state,
507 auth::inject_auth_state,
508 ));
509
510 #[cfg(feature = "metrics")]
513 {
514 app = app.layer(middleware::from_fn(metrics::http_metrics_middleware));
515 }
516
517 #[cfg(feature = "metrics")]
518 {
519 let metrics_state = metrics::MetricsState {
520 handle: prometheus_handle,
521 start_time: server_start_time,
522 };
523 app = app.route(
524 "/metrics",
525 axum::routing::get(metrics::metrics_endpoint).with_state(metrics_state),
526 );
527 }
528
529 let app = app
530 .layer(middleware::from_fn(request_id::request_id_middleware))
531 .layer(TraceLayer::new_for_http().make_span_with(
532 |request: &axum::http::Request<axum::body::Body>| {
533 tracing::debug_span!(
534 "http-request",
535 method = %request.method(),
536 path = %trace_request_path(request),
537 version = ?request.version(),
538 )
539 },
540 ))
541 .layer(cors);
542
543 let addr: SocketAddr = format!("{bind}:{port}").parse()?;
544 let version = mold_core::build_info::version_string();
545 info!(%addr, %version, "starting mold server");
546
547 let listener = TcpListener::bind(addr).await?;
548
549 #[cfg(feature = "mdns")]
555 let mdns_guard = {
556 let bound_port = listener.local_addr()?.port();
557 if mdns::enabled_from_env() && mdns::is_advertisable(bind, bound_port) {
558 let txt = mdns::build_txt_records(
559 mold_core::build_info::VERSION,
560 mold_core::build_info::GIT_SHA,
561 mdns_auth_required,
562 &mdns_gpu_summary,
563 queue_size,
564 &mdns_instance_id,
565 );
566 match mdns::register(bind, bound_port, txt) {
567 Ok(guard) => Some(guard),
568 Err(e) => {
569 tracing::warn!(error = %format!("{e:#}"), "mDNS advertising disabled");
570 None
571 }
572 }
573 } else {
574 tracing::debug!("mDNS advertising skipped (disabled or loopback bind)");
575 None
576 }
577 };
578
579 let server = std::future::IntoFuture::into_future(
580 axum::serve(
581 listener,
582 app.into_make_service_with_connect_info::<SocketAddr>(),
583 )
584 .with_graceful_shutdown(async move {
585 let _ = shutdown_rx.await;
586 tracing::info!("shutting down");
587 }),
588 );
589 tokio::pin!(server);
590 tokio::select! {
591 result = &mut server => result?,
592 _ = fatal_cuda_shutdown.notified() => {
593 tracing::error!("fatal CUDA context error; stopping server for process restart");
594 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
599 }
600 }
601
602 #[cfg(feature = "mdns")]
607 if let Some(guard) = mdns_guard {
608 guard.shutdown();
609 }
610 downloads_shutdown.cancel();
611 downloads_driver.abort();
612 idle_evict_handle.abort();
613 resources_aggregator.abort();
616
617 if fatal_cuda_error.load(std::sync::atomic::Ordering::SeqCst) {
618 anyhow::bail!("fatal CUDA context error; server restart required");
619 }
620
621 Ok(())
622}
623
624fn spawn_cache_idle_evictor(
637 legacy_cache: std::sync::Arc<tokio::sync::Mutex<model_cache::ModelCache>>,
638 legacy_load_lock: std::sync::Arc<tokio::sync::Mutex<()>>,
639 gpu_pool: std::sync::Arc<gpu_pool::GpuPool>,
640 ttl: std::time::Duration,
641) -> tokio::task::JoinHandle<()> {
642 use tokio::time::{interval, MissedTickBehavior};
643 tokio::spawn(async move {
644 let mut tick = interval(std::time::Duration::from_secs(60));
645 tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
646 tick.tick().await;
649 loop {
650 tick.tick().await;
651
652 {
659 let _load_guard = legacy_load_lock.lock().await;
660 let evicted = {
661 let mut cache = legacy_cache.lock().await;
662 cache.evict_idle(ttl)
663 };
664 let evicted_count = evicted.len();
665 drop(evicted);
668
669 let legacy_active = legacy_cache.lock().await.active_model().is_some();
675 if evicted_count > 0 && !legacy_active {
676 tokio::task::spawn_blocking(|| {
677 mold_inference::reclaim_gpu_memory(0);
678 })
679 .await
680 .ok();
681 }
682 }
683
684 for worker in &gpu_pool.workers {
690 let worker = worker.clone();
691 let ttl = ttl;
692 tokio::task::spawn_blocking(move || {
693 let _load_guard = match worker.model_load_lock.lock() {
694 Ok(g) => g,
695 Err(poisoned) => poisoned.into_inner(),
696 };
697 let evicted = {
698 let mut cache =
699 worker.model_cache.lock().unwrap_or_else(|e| e.into_inner());
700 cache.evict_idle(ttl)
701 };
702 let evicted_count = evicted.len();
703 drop(evicted);
704
705 let active = worker
706 .model_cache
707 .lock()
708 .map(|c| c.active_model().is_some())
709 .unwrap_or(true);
710 if evicted_count > 0 && !active {
711 mold_inference::device::init_thread_gpu_ordinal(worker.gpu.ordinal);
717 mold_inference::reclaim_gpu_memory(worker.gpu.ordinal);
718 mold_inference::device::clear_thread_gpu_ordinal();
719 }
720 })
721 .await
722 .ok();
723 }
724 }
725 })
726}
727
728fn build_cors_layer() -> Result<CorsLayer> {
729 let cors = match std::env::var("MOLD_CORS_ORIGIN") {
730 Ok(origin) if !origin.is_empty() => {
731 let origin = origin
732 .parse::<axum::http::HeaderValue>()
733 .map_err(|_| anyhow::anyhow!("invalid MOLD_CORS_ORIGIN value: {origin}"))?;
734 CorsLayer::new()
735 .allow_origin(origin)
736 .allow_methods([
737 axum::http::Method::GET,
738 axum::http::Method::HEAD,
739 axum::http::Method::POST,
740 axum::http::Method::DELETE,
741 ])
742 .allow_headers(tower_http::cors::Any)
743 .expose_headers([
744 axum::http::header::HeaderName::from_static("x-mold-seed-used"),
745 axum::http::header::HeaderName::from_static("x-request-id"),
746 axum::http::header::HeaderName::from_static("retry-after"),
747 axum::http::header::HeaderName::from_static("x-mold-video-frames"),
748 axum::http::header::HeaderName::from_static("x-mold-video-fps"),
749 axum::http::header::HeaderName::from_static("x-mold-video-width"),
750 axum::http::header::HeaderName::from_static("x-mold-video-height"),
751 axum::http::header::HeaderName::from_static("x-mold-video-has-audio"),
752 axum::http::header::HeaderName::from_static("x-mold-video-duration-ms"),
753 axum::http::header::HeaderName::from_static("x-mold-video-audio-sample-rate"),
754 axum::http::header::HeaderName::from_static("x-mold-video-audio-channels"),
755 axum::http::header::HeaderName::from_static("x-mold-dimension-warning"),
756 ])
757 }
758 _ => CorsLayer::permissive(),
759 };
760 Ok(cors)
761}
762
763#[cfg(test)]
764mod tests {
765 use super::{build_cors_layer, trace_request_path};
766 use std::sync::Mutex;
767
768 static ENV_LOCK: Mutex<()> = Mutex::new(());
769
770 #[test]
771 fn invalid_cors_origin_returns_error() {
772 let _lock = ENV_LOCK.lock().unwrap();
773 std::env::set_var("MOLD_CORS_ORIGIN", "\nnot-a-header");
774 let result = build_cors_layer();
775 std::env::remove_var("MOLD_CORS_ORIGIN");
776 assert!(result.is_err());
777 }
778
779 #[test]
780 fn valid_cors_origin_builds_layer() {
781 let _lock = ENV_LOCK.lock().unwrap();
782 std::env::set_var("MOLD_CORS_ORIGIN", "https://example.com");
783 let result = build_cors_layer();
784 std::env::remove_var("MOLD_CORS_ORIGIN");
785 assert!(result.is_ok());
786 }
787
788 #[test]
789 fn trace_path_omits_gallery_bearer_ticket_query() {
790 let request = axum::http::Request::builder()
791 .uri("/api/gallery/image/clip.mp4?media_token=secret-ticket&expires=1900")
792 .body(())
793 .unwrap();
794
795 let path = trace_request_path(&request);
796 assert_eq!(path, "/api/gallery/image/clip.mp4");
797 assert!(!path.contains("secret-ticket"));
798 }
799}