1use std::net::SocketAddr;
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, RwLock};
16use std::time::Duration;
17
18use bhtune_cli::{config, db, logging};
19
20use crate::active_run::ActiveRun;
21use crate::{AppState, build_router};
22
23const SHUTDOWN_RUN_CANCEL_TIMEOUT: Duration = Duration::from_secs(35);
28
29const RETENTION_SWEEP_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
35
36pub struct BoundServer {
43 listener: tokio::net::TcpListener,
44 app: axum::Router,
45 active_run: ActiveRun,
46 log_guard: anyhow::Result<tracing_appender::non_blocking::WorkerGuard>,
52}
53
54pub async fn build_server(config_path: Option<&Path>) -> anyhow::Result<BoundServer> {
62 let loaded_config = config::load_config_store(config_path)
63 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
64 let config = loaded_config.config.clone();
65 let mode =
66 config::resolve_server_mode(std::env::var("BHTUNE_SERVER_MODE").ok().as_deref(), &config)
67 .map_err(|error| anyhow::anyhow!(error))?;
68 let demo_policy = if mode == config::ServerMode::Demo {
69 config::resolve_demo_policy_from_config(&config).map_err(|error| anyhow::anyhow!(error))?
70 } else {
71 config::DemoPolicy::default()
72 };
73 let bind_addr = config::resolve_bind_addr(std::env::var("BHTUNE_BIND").ok(), &config);
74 let addr: SocketAddr = bind_addr
75 .parse()
76 .map_err(|e| anyhow::anyhow!("invalid bind address '{bind_addr}': {e}"))?;
77 let allowed_origin = config::resolve_origin(
78 std::env::var("BHTUNE_ORIGIN").ok(),
79 &config,
80 &bind_addr,
81 mode,
82 )
83 .map_err(|error| anyhow::anyhow!(error))?;
84 if mode == config::ServerMode::Demo {
85 config::validate_demo_trusted_proxy(config.trusted_proxy.as_deref())
86 .map_err(|error| anyhow::anyhow!(error))?;
87 }
88
89 let default_log_dir = config::default_log_dir_from(
90 std::env::var("XDG_DATA_HOME").ok().as_deref(),
91 std::env::var("HOME").ok().as_deref(),
92 std::env::var("APPDATA").ok().as_deref(),
93 cfg!(target_os = "windows"),
94 );
95 let log_settings = logging::resolve_log_settings(
96 std::env::var("RUST_LOG").ok(),
97 None,
98 None,
99 None,
100 &config.log,
101 &default_log_dir,
102 );
103 let log_guard = logging::init_tracing(&log_settings);
104
105 let db_path = config::resolve_db_path(
106 std::env::var("BHTUNE_DB").ok().map(PathBuf::from),
107 &config,
108 std::env::var("XDG_DATA_HOME").ok().as_deref(),
109 std::env::var("HOME").ok().as_deref(),
110 std::env::var("APPDATA").ok().as_deref(),
111 cfg!(target_os = "windows"),
112 );
113 let user_templates = config::load_user_templates(
114 std::env::var("BHTUNE_TEMPLATES").ok().map(PathBuf::from),
115 &config,
116 std::env::var("XDG_CONFIG_HOME").ok().as_deref(),
117 std::env::var("HOME").ok().as_deref(),
118 std::env::var("APPDATA").ok().as_deref(),
119 cfg!(target_os = "windows"),
120 )?;
121 let retention_days = config::resolve_retention_days(
122 std::env::var("BHTUNE_RETENTION_DAYS")
123 .ok()
124 .and_then(|s| s.parse().ok()),
125 &config,
126 );
127 let pool = db::open(&db_path, user_templates, retention_days).await?;
128 if mode == config::ServerMode::Demo {
129 let now = chrono::Utc::now();
130 bhtune_db::models::DemoSessionRow::recover_running_demo_runs(&pool, now).await?;
131 bhtune_db::models::DemoSessionRow::cleanup_expired(&pool, now).await?;
132 bhtune_db::models::TuneRunRow::prune_terminal_demo_owned(
133 &pool,
134 demo_policy.retained_runs_per_visitor,
135 )
136 .await?;
137 }
138
139 let config_store = Arc::new(RwLock::new(loaded_config));
140 spawn_retention_sweeper(pool.clone(), config_store.clone());
141
142 let state = AppState::for_mode_with_network_config(
143 pool,
144 config_store,
145 mode,
146 demo_policy,
147 Some(allowed_origin),
148 config.trusted_proxy.clone(),
149 );
150 if mode == config::ServerMode::Demo {
151 spawn_demo_cleanup(state.clone());
152 }
153 let active_run = state.active_run.clone();
154 let app = build_router(state);
155 let listener = tokio::net::TcpListener::bind(addr).await?;
156 let local_addr = listener.local_addr()?;
161 tracing::info!(%local_addr, "bhtune-server listening");
162 println!("bhtune-server listening on http://{local_addr}");
163
164 Ok(BoundServer {
165 listener,
166 app,
167 active_run,
168 log_guard,
169 })
170}
171
172pub async fn serve(
177 server: BoundServer,
178 shutdown: impl Future<Output = ()> + Send + 'static,
179) -> anyhow::Result<()> {
180 let BoundServer {
186 listener,
187 app,
188 active_run,
189 log_guard: _log_guard,
190 } = server;
191
192 serve_http(
193 axum::serve(
194 listener,
195 app.into_make_service_with_connect_info::<SocketAddr>(),
196 )
197 .with_graceful_shutdown(shutdown),
198 active_run,
199 )
200 .await
201}
202
203async fn serve_http(
204 server: impl std::future::IntoFuture<Output = std::io::Result<()>>,
205 active_run: ActiveRun,
206) -> anyhow::Result<()> {
207 server.into_future().await?;
208 active_run
213 .cancel_and_wait(SHUTDOWN_RUN_CANCEL_TIMEOUT)
214 .await;
215
216 Ok(())
217}
218
219pub async fn shutdown_signal() {
226 let ctrl_c = async {
227 tokio::signal::ctrl_c()
228 .await
229 .expect("failed to install Ctrl+C handler");
230 };
231
232 #[cfg(unix)]
233 let terminate = async {
234 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
235 .expect("failed to install SIGTERM handler")
236 .recv()
237 .await;
238 };
239 #[cfg(not(unix))]
240 let terminate = std::future::pending::<()>();
241
242 tokio::select! {
243 () = ctrl_c => {},
244 () = terminate => {},
245 }
246 tracing::info!("shutdown signal received, draining in-flight requests");
247}
248
249fn spawn_retention_sweeper(
260 pool: bhtune_db::SqlitePool,
261 config_store: Arc<RwLock<bhtune_cli::config::LoadedConfigStore>>,
262) {
263 tokio::spawn(async move {
264 let mut interval = tokio::time::interval(RETENTION_SWEEP_INTERVAL);
265 interval.tick().await;
268 loop {
269 interval.tick().await;
270 retention_tick_live(&pool, &config_store).await;
271 }
272 });
273}
274
275fn spawn_demo_cleanup(state: AppState) {
279 let interval_duration = Duration::from_secs(state.demo_policy.cleanup_interval_secs);
280 tokio::spawn(async move {
281 let mut interval = tokio::time::interval(interval_duration);
282 loop {
283 interval.tick().await;
284 demo_cleanup_tick(&state).await;
285 }
286 });
287}
288
289async fn demo_cleanup_tick(state: &AppState) {
290 let now = chrono::Utc::now();
291 state.demo_runtime.cleanup(now, state.demo_policy).await;
292 if let Err(error) = bhtune_db::models::DemoSessionRow::cleanup_expired(&state.pool, now).await {
293 tracing::warn!(error = %error, "demo session cleanup failed; will retry next interval");
294 }
295 if let Err(error) = bhtune_db::models::TuneRunRow::prune_terminal_demo_owned(
296 &state.pool,
297 state.demo_policy.retained_runs_per_visitor,
298 )
299 .await
300 {
301 tracing::warn!(error = %error, "demo history cleanup failed; will retry next interval");
302 }
303}
304
305async fn retention_tick_live(
306 pool: &bhtune_db::SqlitePool,
307 config_store: &Arc<RwLock<bhtune_cli::config::LoadedConfigStore>>,
308) {
309 let config = match config_store.read() {
310 Ok(store) => store.config.clone(),
311 Err(_) => {
312 tracing::warn!("configuration store lock poisoned; skipping retention sweep");
313 return;
314 }
315 };
316 let env_days = std::env::var("BHTUNE_RETENTION_DAYS")
317 .ok()
318 .and_then(|value| value.parse().ok());
319 let Some(days) = bhtune_cli::config::resolve_retention_days(env_days, &config) else {
320 return;
321 };
322 retention_tick(pool, days).await;
323}
324
325async fn retention_tick(pool: &bhtune_db::SqlitePool, days: u32) {
332 let now = chrono::Utc::now();
333 if let Err(e) = bhtune_cli::retention::sweep_retention(pool, days, now).await {
334 tracing::warn!(error = %e, "periodic retention sweep failed; will retry next interval");
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use bhtune_db::connect_in_memory;
342
343 #[test]
344 fn shutdown_and_retention_intervals_match_the_documented_policy() {
345 assert_eq!(SHUTDOWN_RUN_CANCEL_TIMEOUT, Duration::from_secs(35));
346 assert_eq!(RETENTION_SWEEP_INTERVAL, Duration::from_secs(24 * 60 * 60));
347 }
348
349 #[tokio::test]
350 async fn build_server_propagates_a_malformed_config_file() {
351 let dir = tempfile::tempdir().unwrap();
352 let path = dir.path().join("bhtune.toml");
353 std::fs::write(&path, "retention_days = [not valid toml").unwrap();
354
355 let result = build_server(Some(&path)).await;
356 assert!(result.is_err());
357 let error = result.err().unwrap();
358 assert!(error.to_string().contains("failed to parse config file"));
359 }
360
361 #[tokio::test]
362 async fn build_server_propagates_a_missing_explicit_template_catalog() {
363 let dir = tempfile::tempdir().unwrap();
364 let config_path = dir.path().join("bhtune.toml");
365 let templates_path = dir.path().join("missing-templates.toml");
366 std::fs::write(&config_path, format!("templates = {:?}\n", templates_path)).unwrap();
367
368 let result = build_server(Some(&config_path)).await;
369 assert!(result.is_err());
370 let error = result.err().unwrap();
371 assert!(error.to_string().contains("templates file not found"));
372 }
373
374 #[tokio::test]
375 async fn build_server_rejects_an_insecure_non_loopback_demo_origin_before_opening_the_db() {
376 let dir = tempfile::tempdir().unwrap();
377 let config_path = dir.path().join("bhtune.toml");
378 let db_path = dir.path().join("must-not-exist.db");
379 std::fs::write(
380 &config_path,
381 format!(
382 "server_mode = \"demo\"\norigin = \"http://demo.example\"\n\
383 bind = \"127.0.0.1:0\"\ndb = {:?}\n",
384 db_path
385 ),
386 )
387 .unwrap();
388
389 let error = build_server(Some(&config_path)).await.err().unwrap();
390
391 assert!(error.to_string().contains("must use HTTPS"));
392 assert!(!db_path.exists());
393 }
394
395 #[tokio::test]
396 async fn build_server_rejects_an_invalid_demo_policy_before_opening_the_db() {
397 let dir = tempfile::tempdir().unwrap();
398 let config_path = dir.path().join("bhtune.toml");
399 let db_path = dir.path().join("must-not-exist.db");
400 std::fs::write(
401 &config_path,
402 format!(
403 "server_mode = \"demo\"\nbind = \"127.0.0.1:0\"\ndb = {:?}\n\
404 [demo]\nmax_active_runs_global = 9\n",
405 db_path
406 ),
407 )
408 .unwrap();
409
410 let error = build_server(Some(&config_path)).await.err().unwrap();
411
412 assert!(
413 error
414 .to_string()
415 .contains("demo.max_active_runs_global must be exactly 8")
416 );
417 assert!(!db_path.exists());
418 }
419
420 #[tokio::test]
421 async fn build_server_rejects_an_invalid_demo_trusted_proxy_before_opening_the_db() {
422 let dir = tempfile::tempdir().unwrap();
423 let config_path = dir.path().join("bhtune.toml");
424 let db_path = dir.path().join("must-not-exist.db");
425 std::fs::write(
426 &config_path,
427 format!(
428 "server_mode = \"demo\"\norigin = \"http://localhost\"\n\
429 bind = \"127.0.0.1:0\"\n\
430 trusted_proxy = \"proxy.example\"\ndb = {:?}\n",
431 db_path
432 ),
433 )
434 .unwrap();
435
436 let error = build_server(Some(&config_path)).await.err().unwrap();
437
438 assert!(error.to_string().contains("trusted_proxy"));
439 assert!(!db_path.exists());
440 }
441
442 #[tokio::test]
443 async fn build_server_starts_demo_cleanup_before_serving() {
444 let dir = tempfile::tempdir().unwrap();
445 let config_path = dir.path().join("bhtune.toml");
446 let db_path = dir.path().join("bhtune.db");
447 std::fs::write(
448 &config_path,
449 format!(
450 "server_mode = \"demo\"\norigin = \"http://localhost\"\n\
451 db = {:?}\nbind = \"127.0.0.1:0\"\n",
452 db_path
453 ),
454 )
455 .unwrap();
456
457 let server = build_server(Some(&config_path)).await.unwrap();
458 serve(server, async {}).await.unwrap();
459 }
460
461 async fn insert_old_run(pool: &bhtune_db::SqlitePool) -> i64 {
462 use bhtune_core::{ControllerType, LoopConfig, LoopTags, ProcessType, built_in_templates};
463 use bhtune_db::models::{TemplateOrigin, TuneDriver, TuneRunRow};
464
465 let template = built_in_templates().remove(0);
466 let tags = LoopTags::derive_from_pv_tag("Unit1.LIC101.PV", &template);
467 let config = LoopConfig {
468 process_type: ProcessType::Flow,
469 controller_type: ControllerType::Pi,
470 relay_amp_percent: 5.0,
471 num_cycles_skip: 1,
472 num_cycles_count: 2,
473 noise_protection_secs: 3,
474 mrft_delay_secs: 0,
475 };
476 let old_started_at = chrono::Utc::now() - chrono::Duration::days(100);
477 TuneRunRow::start(
478 pool,
479 None,
480 "LIC-X",
481 TuneDriver::Simulator,
482 config,
483 TemplateOrigin::Builtin,
484 &template,
485 &tags,
486 old_started_at,
487 )
488 .await
489 .unwrap()
490 .id
491 }
492
493 #[tokio::test]
494 async fn retention_tick_deletes_runs_past_the_cutoff_and_logs_nothing_fatal() {
495 let pool = connect_in_memory().await.unwrap();
496 let old_run_id = insert_old_run(&pool).await;
497
498 retention_tick(&pool, 30).await;
499
500 assert!(
501 bhtune_db::models::TuneRunRow::get(&pool, old_run_id)
502 .await
503 .unwrap()
504 .is_none()
505 );
506 }
507
508 #[tokio::test]
509 async fn retention_tick_on_a_pool_with_no_matching_runs_is_a_silent_no_op() {
510 let pool = connect_in_memory().await.unwrap();
511 retention_tick(&pool, 30).await;
514 }
515
516 #[tokio::test]
517 async fn build_server_completes_startup_and_can_serve_until_shutdown() {
518 let dir = tempfile::tempdir().unwrap();
519 let config_path = dir.path().join("bhtune.toml");
520 let db_path = dir.path().join("bhtune.db");
521 let log_dir = dir.path().join("logs");
522 std::fs::write(
523 &config_path,
524 format!(
525 "db = {:?}\nbind = \"127.0.0.1:0\"\n[log]\ndir = {:?}\n",
526 db_path, log_dir
527 ),
528 )
529 .unwrap();
530
531 let server = build_server(Some(&config_path)).await.unwrap();
532 serve(server, async {}).await.unwrap();
533 }
534
535 #[tokio::test]
536 async fn serve_propagates_an_http_server_error() {
537 let error = serve_http(
538 async {
539 Err(std::io::Error::new(
540 std::io::ErrorKind::BrokenPipe,
541 "injected accept failure",
542 ))
543 },
544 ActiveRun::default(),
545 )
546 .await
547 .unwrap_err();
548
549 assert!(error.to_string().contains("injected accept failure"));
550 }
551
552 #[tokio::test]
553 async fn retention_tick_live_runs_when_retention_is_configured() {
554 let pool = connect_in_memory().await.unwrap();
555 let old_run_id = insert_old_run(&pool).await;
556 let store = Arc::new(RwLock::new(bhtune_cli::config::LoadedConfigStore {
557 path: None,
558 missing_is_allowed: true,
559 original_raw: None,
560 config: bhtune_cli::config::BhtuneConfig {
561 retention_days: Some(30),
562 ..Default::default()
563 },
564 revision: "revision".to_string(),
565 toml_allow_uncertain_quality: None,
566 toml_tuning: Default::default(),
567 tuning_sources: bhtune_cli::config::tuning_config_sources(
568 &bhtune_cli::config::TuningConfig::default(),
569 ),
570 }));
571
572 retention_tick_live(&pool, &store).await;
573
574 assert!(
575 bhtune_db::models::TuneRunRow::get(&pool, old_run_id)
576 .await
577 .unwrap()
578 .is_none()
579 );
580 }
581
582 #[tokio::test]
583 async fn retention_tick_live_skips_when_retention_is_disabled() {
584 let pool = connect_in_memory().await.unwrap();
585 let store = Arc::new(RwLock::new(bhtune_cli::config::LoadedConfigStore {
586 path: None,
587 missing_is_allowed: true,
588 original_raw: None,
589 config: Default::default(),
590 revision: "revision".to_string(),
591 toml_allow_uncertain_quality: None,
592 toml_tuning: Default::default(),
593 tuning_sources: bhtune_cli::config::tuning_config_sources(
594 &bhtune_cli::config::TuningConfig::default(),
595 ),
596 }));
597
598 retention_tick_live(&pool, &store).await;
599 }
600
601 #[tokio::test]
602 async fn retention_tick_live_skips_when_config_store_lock_is_poisoned() {
603 let pool = connect_in_memory().await.unwrap();
604 let store = Arc::new(RwLock::new(bhtune_cli::config::LoadedConfigStore {
605 path: None,
606 missing_is_allowed: true,
607 original_raw: None,
608 config: Default::default(),
609 revision: "revision".to_string(),
610 toml_allow_uncertain_quality: None,
611 toml_tuning: Default::default(),
612 tuning_sources: bhtune_cli::config::tuning_config_sources(
613 &bhtune_cli::config::TuningConfig::default(),
614 ),
615 }));
616 let poisoned = Arc::clone(&store);
617 std::thread::spawn(move || {
618 let _guard = poisoned.write().unwrap();
619 panic!("poison configuration store");
620 })
621 .join()
622 .unwrap_err();
623
624 retention_tick_live(&pool, &store).await;
625 }
626
627 #[tokio::test]
628 async fn retention_tick_live_logs_and_returns_when_sweep_fails() {
629 let pool = connect_in_memory().await.unwrap();
630 pool.close().await;
631 let store = Arc::new(RwLock::new(bhtune_cli::config::LoadedConfigStore {
632 path: None,
633 missing_is_allowed: true,
634 original_raw: None,
635 config: bhtune_cli::config::BhtuneConfig {
636 retention_days: Some(30),
637 ..Default::default()
638 },
639 revision: "revision".to_string(),
640 toml_allow_uncertain_quality: None,
641 toml_tuning: Default::default(),
642 tuning_sources: bhtune_cli::config::tuning_config_sources(
643 &bhtune_cli::config::TuningConfig::default(),
644 ),
645 }));
646
647 retention_tick_live(&pool, &store).await;
648 }
649
650 #[tokio::test]
651 async fn demo_cleanup_tick_succeeds_and_retries_database_failures() {
652 let state = crate::test_support::in_memory_state().await;
653 demo_cleanup_tick(&state).await;
654
655 state.pool.close().await;
656 demo_cleanup_tick(&state).await;
657 }
658
659 #[tokio::test(start_paused = true)]
660 async fn spawned_demo_cleanup_runs_on_the_policy_interval() {
661 tokio::time::resume();
662 let state = crate::test_support::in_memory_state().await;
663 tokio::time::pause();
664 spawn_demo_cleanup(state);
665 tokio::task::yield_now().await;
666 tokio::time::advance(Duration::from_secs(
667 bhtune_cli::config::DEMO_CLEANUP_INTERVAL_SECS,
668 ))
669 .await;
670 tokio::time::resume();
671 tokio::task::yield_now().await;
672 }
673
674 #[tokio::test(start_paused = true)]
675 async fn spawned_retention_sweeper_runs_a_periodic_tick() {
676 tokio::time::resume();
677 let pool = connect_in_memory().await.unwrap();
678 let old_run_id = insert_old_run(&pool).await;
679 tokio::time::pause();
680 let store = Arc::new(RwLock::new(bhtune_cli::config::LoadedConfigStore {
681 path: None,
682 missing_is_allowed: true,
683 original_raw: None,
684 config: bhtune_cli::config::BhtuneConfig {
685 retention_days: Some(30),
686 ..Default::default()
687 },
688 revision: "revision".to_string(),
689 toml_allow_uncertain_quality: None,
690 toml_tuning: Default::default(),
691 tuning_sources: bhtune_cli::config::tuning_config_sources(
692 &bhtune_cli::config::TuningConfig::default(),
693 ),
694 }));
695
696 spawn_retention_sweeper(pool.clone(), store);
697 tokio::task::yield_now().await;
698 tokio::time::advance(RETENTION_SWEEP_INTERVAL + Duration::from_secs(1)).await;
699 tokio::time::resume();
700 for _ in 0..3 {
701 tokio::task::yield_now().await;
702 }
703
704 assert!(
705 bhtune_db::models::TuneRunRow::get(&pool, old_run_id)
706 .await
707 .unwrap()
708 .is_none()
709 );
710 }
711}