Skip to main content

bhtune_server/
run.rs

1//! The actual `bhtune-server` bootstrap-and-serve sequence, split out of `main.rs` so it can
2//! be driven either directly from an interactive console session or a systemd/launchd-managed
3//! foreground process, or from inside the
4//! Windows Service Control Manager's own callback thread (`crate::service`'s
5//! `#[cfg(windows)]` glue), which needs its *own* shutdown trigger (an SCM Stop/Shutdown
6//! control event) instead of Ctrl+C/`SIGTERM`.
7//!
8//! Split into two phases rather than one long function, so a caller that needs to know the
9//! exact moment the server is actually ready to accept connections (the Windows service path
10//! reports `SERVICE_RUNNING` to the SCM at that point, not a moment earlier) can await
11//! [`build_server`] and only then move on -- see [`BoundServer`].
12
13use std::net::SocketAddr;
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, RwLock};
16use std::time::Duration;
17
18use bhtune_runtime::{config, db, logging};
19
20#[cfg(unix)]
21use anyhow::Context;
22
23use crate::active_run::ActiveRun;
24use crate::{AppState, build_router};
25
26/// How long graceful shutdown waits for an in-flight tune run to actually finish cancelling
27/// (its restore attempt included) after `axum::serve` itself has finished draining
28/// in-flight HTTP connections, before giving up and exiting anyway -- see
29/// [`ActiveRun::cancel_and_wait`]'s own doc comment for what "giving up" logs.
30const SHUTDOWN_RUN_CANCEL_TIMEOUT: Duration = Duration::from_secs(35);
31
32/// How often the server re-applies the retention policy for as long as it keeps
33/// running, on top of the one-shot sweep `db::open` already ran at startup. A day is far
34/// more than frequent enough for an age-based-in-days policy -- the oldest a run can ever
35/// linger past its cutoff is one interval -- while being infrequent enough that the sweep
36/// never meaningfully competes with real traffic for the database.
37const RETENTION_SWEEP_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
38
39/// A `bhtune-server` that has finished every startup step through binding its listening
40/// socket -- config/log/db resolution, migrations, template/retention seeding -- and is
41/// ready to actually start accepting connections, but has not yet been handed to
42/// [`serve`]. Returned as one value (rather than `serve` doing all of this itself) so a
43/// caller can observe "fully bound and ready" as a distinct moment from "now serving until
44/// told to stop" -- see this module's doc comment.
45pub struct BoundServer {
46    listener: tokio::net::TcpListener,
47    app: axum::Router,
48    active_run: ActiveRun,
49    // Held for as long as `BoundServer` (and, transitively, whatever `serve` destructures it
50    // into) lives -- dropping it any earlier risks silently truncating buffered log lines,
51    // per `logging::init_tracing`'s own doc comment. Not unwrapped, matching the original
52    // `main.rs`'s own `let _log_guard = logging::init_tracing(..)` -- a logging setup failure
53    // (e.g. an unwritable log directory) shouldn't itself prevent the server from starting.
54    log_guard: anyhow::Result<tracing_appender::non_blocking::WorkerGuard>,
55}
56
57/// Runs every step of `bhtune-server`'s startup through binding its listening socket:
58/// resolve config (an explicit `config_path` if given, otherwise the platform's
59/// auto-discovered path through the shared runtime resolver), init logging,
60/// open/migrate/seed the database, spawn the
61/// periodic retention sweeper, and bind the configured address.
62///
63/// Does not start serving -- see [`serve`].
64pub async fn build_server(config_path: Option<&Path>) -> anyhow::Result<BoundServer> {
65    let loaded_config = config::load_config_store(config_path)
66        .map_err(|error| anyhow::anyhow!(error.to_string()))?;
67    let config = loaded_config.config.clone();
68    let mode =
69        config::resolve_server_mode(std::env::var("BHTUNE_SERVER_MODE").ok().as_deref(), &config)
70            .map_err(|error| anyhow::anyhow!(error))?;
71    let demo_policy = if mode == config::ServerMode::Demo {
72        config::resolve_demo_policy_from_config(&config).map_err(|error| anyhow::anyhow!(error))?
73    } else {
74        config::DemoPolicy::default()
75    };
76    let bind_addr = config::resolve_bind_addr(std::env::var("BHTUNE_BIND").ok(), &config);
77    let addr: SocketAddr = bind_addr
78        .parse()
79        .map_err(|e| anyhow::anyhow!("invalid bind address '{bind_addr}': {e}"))?;
80    let allowed_origin = config::resolve_origin(
81        std::env::var("BHTUNE_ORIGIN").ok(),
82        &config,
83        &bind_addr,
84        mode,
85    )
86    .map_err(|error| anyhow::anyhow!(error))?;
87    if mode == config::ServerMode::Demo {
88        config::validate_demo_trusted_proxy(config.trusted_proxy.as_deref())
89            .map_err(|error| anyhow::anyhow!(error))?;
90    }
91
92    let default_log_dir = config::default_log_dir_from(
93        std::env::var("XDG_DATA_HOME").ok().as_deref(),
94        std::env::var("HOME").ok().as_deref(),
95        std::env::var("APPDATA").ok().as_deref(),
96        cfg!(target_os = "windows"),
97    );
98    let log_settings = logging::resolve_log_settings(
99        std::env::var("RUST_LOG").ok(),
100        None,
101        None,
102        None,
103        &config.log,
104        &default_log_dir,
105    );
106    let log_guard = logging::init_tracing(&log_settings);
107
108    let db_path = config::resolve_db_path(
109        std::env::var("BHTUNE_DB").ok().map(PathBuf::from),
110        &config,
111        std::env::var("XDG_DATA_HOME").ok().as_deref(),
112        std::env::var("HOME").ok().as_deref(),
113        std::env::var("APPDATA").ok().as_deref(),
114        cfg!(target_os = "windows"),
115    );
116    let user_templates = config::load_user_templates(
117        std::env::var("BHTUNE_TEMPLATES").ok().map(PathBuf::from),
118        &config,
119        std::env::var("XDG_CONFIG_HOME").ok().as_deref(),
120        std::env::var("HOME").ok().as_deref(),
121        std::env::var("APPDATA").ok().as_deref(),
122        cfg!(target_os = "windows"),
123    )?;
124    let retention_days = config::resolve_retention_days(
125        std::env::var("BHTUNE_RETENTION_DAYS")
126            .ok()
127            .and_then(|s| s.parse().ok()),
128        &config,
129    );
130    let pool = if mode == config::ServerMode::Demo {
131        db::open_demo(&db_path, user_templates, retention_days).await?
132    } else {
133        db::open(&db_path, user_templates, retention_days).await?
134    };
135    if mode == config::ServerMode::Demo {
136        let now = chrono::Utc::now();
137        bhtune_db::models::DemoSessionRow::recover_running_demo_runs(&pool, now).await?;
138        bhtune_db::models::DemoSessionRow::cleanup_expired(&pool, now).await?;
139        bhtune_db::models::TuneRunRow::prune_terminal_demo_owned(
140            &pool,
141            demo_policy.retained_runs_per_visitor,
142        )
143        .await?;
144    }
145    let preflight_database = if mode == config::ServerMode::Full {
146        db::open_read_only(&db_path).await?
147    } else {
148        None
149    };
150
151    let config_store = Arc::new(RwLock::new(loaded_config));
152    spawn_retention_sweeper(pool.clone(), config_store.clone());
153
154    let state = AppState::for_mode_with_network_config(
155        pool,
156        config_store,
157        mode,
158        demo_policy,
159        allowed_origin,
160        config.trusted_proxy.clone(),
161    )
162    .with_preflight_database(preflight_database);
163    if mode == config::ServerMode::Demo {
164        spawn_demo_cleanup(state.clone());
165    }
166    let active_run = state.active_run.clone();
167    let app = build_router(state);
168    let listener = tokio::net::TcpListener::bind(addr).await?;
169    // Logs the OS-assigned address, not the requested `addr` -- identical for every real
170    // deployment (a concrete port is always configured), but the two differ whenever the
171    // requested port is `0` (bind to any free port), which is exactly what lets tests avoid
172    // hardcoding a port that might collide with something else already listening.
173    let local_addr = listener.local_addr()?;
174    tracing::info!(%local_addr, "bhtune-server listening");
175    println!("bhtune-server listening on http://{local_addr}");
176
177    Ok(BoundServer {
178        listener,
179        app,
180        active_run,
181        log_guard,
182    })
183}
184
185/// Serves `server` until `shutdown` resolves, then drains in-flight HTTP connections and
186/// cancels/waits for any still-active tune run before returning -- the interactive path's
187/// `main.rs` awaits [`shutdown_signal`]; the Windows service path
188/// (`crate::service::windows_impl`) awaits its own SCM-driven signal instead.
189pub async fn serve(
190    server: BoundServer,
191    shutdown: impl Future<Output = ()> + Send + 'static,
192) -> anyhow::Result<()> {
193    // `_log_guard` is never read again, only held: it must simply outlive every `tracing`
194    // call this function makes below, which binding it (rather than discarding it with `_`
195    // in the destructuring pattern, which would drop it immediately) guarantees -- it goes
196    // out of scope, and only then flushes/joins its writer thread, when this function
197    // returns.
198    let BoundServer {
199        listener,
200        app,
201        active_run,
202        log_guard: _log_guard,
203    } = server;
204
205    serve_http(
206        axum::serve(
207            listener,
208            app.into_make_service_with_connect_info::<SocketAddr>(),
209        )
210        .with_graceful_shutdown(shutdown),
211        active_run,
212    )
213    .await
214}
215
216async fn serve_http(
217    server: impl std::future::IntoFuture<Output = std::io::Result<()>>,
218    active_run: ActiveRun,
219) -> anyhow::Result<()> {
220    server.into_future().await?;
221    // Runs *after* axum has finished draining in-flight HTTP connections, not folded into
222    // the shutdown future itself -- so a client mid-`GET /api/runs/:id` during shutdown still
223    // gets its response before this starts cancelling the run it might have been asking
224    // about.
225    active_run
226        .cancel_and_wait(SHUTDOWN_RUN_CANCEL_TIMEOUT)
227        .await;
228
229    Ok(())
230}
231
232/// Waits for Ctrl+C (SIGINT), or on Unix, SIGTERM -- so a service manager's ordinary "stop"
233/// request (`systemctl stop`, `launchctl stop`) drains in-flight requests the same way an
234/// interactive Ctrl+C does, rather than dropping connections mid-response. Unix signal
235/// handlers are installed when this function is called, before the server starts accepting
236/// connections, so an immediate shutdown signal cannot hit the default process handler. The
237/// Windows Service Control Manager's own Stop/Shutdown control codes don't arrive as either of
238/// these signals -- see `crate::service::windows_impl::run_service` for the SCM-specific
239/// equivalent used instead when running as a Windows service. Installing a handler returns
240/// an error instead of panicking, so a failed install cannot take down a process that may
241/// already be serving requests.
242pub fn shutdown_signal() -> anyhow::Result<impl std::future::Future<Output = ()> + Send + 'static> {
243    #[cfg(unix)]
244    {
245        let mut interrupt =
246            tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
247                .context("failed to install SIGINT handler")?;
248        let mut terminate =
249            tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
250                .context("failed to install SIGTERM handler")?;
251
252        Ok(async move {
253            tokio::select! {
254                _ = interrupt.recv() => {},
255                _ = terminate.recv() => {},
256            }
257            tracing::info!("shutdown signal received, draining in-flight requests");
258        })
259    }
260    #[cfg(not(unix))]
261    {
262        Ok(async {
263            if let Err(error) = tokio::signal::ctrl_c().await {
264                tracing::error!(%error, "failed to install Ctrl+C handler; waiting indefinitely");
265                std::future::pending::<()>().await;
266            }
267            tracing::info!("shutdown signal received, draining in-flight requests");
268        })
269    }
270}
271
272/// Spawns the background task that re-applies the retention policy every
273/// [`RETENTION_SWEEP_INTERVAL`] for as long as the server keeps running. The task retains the
274/// synchronized store rather than a copied day count, so a config-page save is observed by
275/// the next sweep; a disabled policy simply makes that tick a no-op.
276///
277/// Not joined or cancelled anywhere: the task only ever does one cheap `DELETE` per tick and
278/// holds no resources between ticks, so letting it end abruptly when the process exits
279/// (rather than folding it into a careful graceful-shutdown sequence) risks losing at most
280/// one in-progress sweep, never corrupting anything -- SQLite's own transaction guarantees
281/// cover the rest.
282fn spawn_retention_sweeper(
283    pool: bhtune_db::SqlitePool,
284    config_store: Arc<RwLock<bhtune_runtime::config::LoadedConfigStore>>,
285) {
286    tokio::spawn(async move {
287        let mut interval = tokio::time::interval(RETENTION_SWEEP_INTERVAL);
288        // The first tick fires immediately; `db::open` already ran a startup sweep moments
289        // ago, so this first iteration would otherwise be a guaranteed-redundant no-op.
290        interval.tick().await;
291        loop {
292            interval.tick().await;
293            retention_tick_live(&pool, &config_store).await;
294        }
295    });
296}
297
298/// Reaps expired anonymous Demo sessions and trims each visitor's terminal history while the
299/// server remains alive. Demo history is bounded independently of the Full-mode age-retention
300/// policy, so it needs its own maintenance loop.
301fn spawn_demo_cleanup(state: AppState) {
302    let interval_duration = Duration::from_secs(state.demo_policy.cleanup_interval_secs);
303    tokio::spawn(async move {
304        let mut interval = tokio::time::interval(interval_duration);
305        loop {
306            interval.tick().await;
307            demo_cleanup_tick(&state).await;
308        }
309    });
310}
311
312async fn demo_cleanup_tick(state: &AppState) {
313    let now = chrono::Utc::now();
314    state.demo_runtime.cleanup(now, state.demo_policy).await;
315    if let Err(error) = bhtune_db::models::DemoSessionRow::cleanup_expired(&state.pool, now).await {
316        tracing::warn!(error = %error, "demo session cleanup failed; will retry next interval");
317    }
318    if let Err(error) = bhtune_db::models::TuneRunRow::prune_terminal_demo_owned(
319        &state.pool,
320        state.demo_policy.retained_runs_per_visitor,
321    )
322    .await
323    {
324        tracing::warn!(error = %error, "demo history cleanup failed; will retry next interval");
325    }
326}
327
328async fn retention_tick_live(
329    pool: &bhtune_db::SqlitePool,
330    config_store: &Arc<RwLock<bhtune_runtime::config::LoadedConfigStore>>,
331) {
332    let config = match config_store.read() {
333        Ok(store) => store.config.clone(),
334        Err(_) => {
335            tracing::warn!("configuration store lock poisoned; skipping retention sweep");
336            return;
337        }
338    };
339    let env_days = std::env::var("BHTUNE_RETENTION_DAYS")
340        .ok()
341        .and_then(|value| value.parse().ok());
342    let Some(days) = bhtune_runtime::config::resolve_retention_days(env_days, &config) else {
343        return;
344    };
345    retention_tick(pool, days).await;
346}
347
348/// One periodic retention sweep. Logs a warning and returns on failure rather than
349/// propagating -- unlike `db::open`'s startup sweep (fatal by design, since a one-shot CLI
350/// invocation failing fast beats silently proceeding on what might be a broken database),
351/// crashing a long-running server over a background maintenance hiccup would drop every
352/// in-flight HTTP connection and any actively-running tune, a far worse outcome than
353/// skipping one sweep and retrying at the next interval.
354async fn retention_tick(pool: &bhtune_db::SqlitePool, days: u32) {
355    let now = chrono::Utc::now();
356    if let Err(e) = bhtune_runtime::retention::sweep_retention(pool, days, now).await {
357        tracing::warn!(error = %e, "periodic retention sweep failed; will retry next interval");
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use bhtune_db::connect_in_memory;
365
366    #[test]
367    fn shutdown_and_retention_intervals_match_the_documented_policy() {
368        assert_eq!(SHUTDOWN_RUN_CANCEL_TIMEOUT, Duration::from_secs(35));
369        assert_eq!(RETENTION_SWEEP_INTERVAL, Duration::from_secs(24 * 60 * 60));
370    }
371
372    #[tokio::test]
373    async fn build_server_propagates_a_malformed_config_file() {
374        let dir = tempfile::tempdir().unwrap();
375        let path = dir.path().join("bhtune.toml");
376        std::fs::write(&path, "retention_days = [not valid toml").unwrap();
377
378        let result = build_server(Some(&path)).await;
379        assert!(result.is_err());
380        let error = result.err().unwrap();
381        assert!(error.to_string().contains("failed to parse config file"));
382    }
383
384    #[tokio::test]
385    async fn build_server_propagates_a_missing_explicit_template_catalog() {
386        let dir = tempfile::tempdir().unwrap();
387        let config_path = dir.path().join("bhtune.toml");
388        let templates_path = dir.path().join("missing-templates.toml");
389        std::fs::write(&config_path, format!("templates = {templates_path:?}\n")).unwrap();
390
391        let result = build_server(Some(&config_path)).await;
392        assert!(result.is_err());
393        let error = result.err().unwrap();
394        assert!(error.to_string().contains("templates file not found"));
395    }
396
397    #[tokio::test]
398    async fn build_server_fails_with_recreate_guidance_for_pre_squash_history() {
399        use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
400
401        let dir = tempfile::tempdir().unwrap();
402        let config_path = dir.path().join("bhtune.toml");
403        let db_path = dir.path().join("pre-squash.db");
404        let pool = SqlitePoolOptions::new()
405            .max_connections(1)
406            .connect_with(
407                SqliteConnectOptions::new()
408                    .filename(&db_path)
409                    .create_if_missing(true),
410            )
411            .await
412            .unwrap();
413        sqlx::query(
414            "CREATE TABLE _sqlx_migrations (\
415                version BIGINT PRIMARY KEY, description TEXT NOT NULL, \
416                installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, \
417                success BOOLEAN NOT NULL, checksum BLOB NOT NULL, execution_time BIGINT NOT NULL\
418            )",
419        )
420        .execute(&pool)
421        .await
422        .unwrap();
423        sqlx::query(
424            "INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) \
425             VALUES (1, 'initial schema', TRUE, zeroblob(48), 0)",
426        )
427        .execute(&pool)
428        .await
429        .unwrap();
430        pool.close().await;
431        std::fs::write(
432            &config_path,
433            format!(
434                "server_mode = \"full\"\nbind = \"127.0.0.1:0\"\ndb = {db_path:?}\n\
435                 [log]\ndir = {:?}\n",
436                dir.path().join("logs")
437            ),
438        )
439        .unwrap();
440
441        let error = build_server(Some(&config_path)).await.err().unwrap();
442        let message = error.to_string();
443        assert!(message.contains(&db_path.display().to_string()));
444        assert!(message.contains("incompatible migration history"));
445        assert!(message.contains("back it up"));
446        assert!(message.contains("new database file"));
447        assert!(!message.contains("failed to run database migrations"));
448    }
449
450    #[tokio::test]
451    async fn build_server_rejects_an_insecure_non_loopback_demo_origin_before_opening_the_db() {
452        let dir = tempfile::tempdir().unwrap();
453        let config_path = dir.path().join("bhtune.toml");
454        let db_path = dir.path().join("must-not-exist.db");
455        std::fs::write(
456            &config_path,
457            format!(
458                "server_mode = \"demo\"\norigin = \"http://demo.example\"\n\
459                 bind = \"127.0.0.1:0\"\ndb = {db_path:?}\n"
460            ),
461        )
462        .unwrap();
463
464        let error = build_server(Some(&config_path)).await.err().unwrap();
465
466        assert!(error.to_string().contains("must use HTTPS"));
467        assert!(!db_path.exists());
468    }
469
470    #[tokio::test]
471    async fn build_server_rejects_an_invalid_demo_policy_before_opening_the_db() {
472        let dir = tempfile::tempdir().unwrap();
473        let config_path = dir.path().join("bhtune.toml");
474        let db_path = dir.path().join("must-not-exist.db");
475        std::fs::write(
476            &config_path,
477            format!(
478                "server_mode = \"demo\"\nbind = \"127.0.0.1:0\"\ndb = {db_path:?}\n\
479                 [demo]\nmax_active_runs_global = 9\n"
480            ),
481        )
482        .unwrap();
483
484        let error = build_server(Some(&config_path)).await.err().unwrap();
485
486        assert!(
487            error
488                .to_string()
489                .contains("demo.max_active_runs_global must be exactly 8")
490        );
491        assert!(!db_path.exists());
492    }
493
494    #[tokio::test]
495    async fn build_server_rejects_an_invalid_demo_trusted_proxy_before_opening_the_db() {
496        let dir = tempfile::tempdir().unwrap();
497        let config_path = dir.path().join("bhtune.toml");
498        let db_path = dir.path().join("must-not-exist.db");
499        std::fs::write(
500            &config_path,
501            format!(
502                "server_mode = \"demo\"\norigin = \"http://localhost\"\n\
503                 bind = \"127.0.0.1:0\"\n\
504                 trusted_proxy = \"proxy.example\"\ndb = {db_path:?}\n"
505            ),
506        )
507        .unwrap();
508
509        let error = build_server(Some(&config_path)).await.err().unwrap();
510
511        assert!(error.to_string().contains("trusted_proxy"));
512        assert!(!db_path.exists());
513    }
514
515    #[tokio::test]
516    async fn build_server_starts_demo_cleanup_before_serving() {
517        let dir = tempfile::tempdir().unwrap();
518        let config_path = dir.path().join("bhtune.toml");
519        let db_path = dir.path().join("bhtune.db");
520        std::fs::write(
521            &config_path,
522            format!(
523                "server_mode = \"demo\"\norigin = \"http://localhost\"\n\
524                 db = {db_path:?}\nbind = \"127.0.0.1:0\"\n"
525            ),
526        )
527        .unwrap();
528
529        let server = build_server(Some(&config_path)).await.unwrap();
530        serve(server, async {}).await.unwrap();
531    }
532
533    async fn insert_old_run(pool: &bhtune_db::SqlitePool) -> i64 {
534        use bhtune_core::{ControllerType, LoopConfig, LoopTags, ProcessType, built_in_templates};
535        use bhtune_db::models::{TemplateOrigin, TuneDriver, TuneRunRow};
536
537        let template = built_in_templates().remove(0);
538        let tags = LoopTags::derive_from_pv_tag("Unit1.LIC101.PV", &template);
539        let config = LoopConfig {
540            process_type: ProcessType::Flow,
541            controller_type: ControllerType::Pi,
542            relay_amp_percent: 5.0,
543            num_cycles_skip: 1,
544            num_cycles_count: 2,
545            noise_protection_secs: 3,
546            mrft_delay_secs: 0,
547        };
548        let old_started_at = chrono::Utc::now() - chrono::Duration::days(100);
549        TuneRunRow::start(
550            pool,
551            None,
552            "LIC-X",
553            TuneDriver::Simulator,
554            config,
555            TemplateOrigin::Builtin,
556            &template,
557            &tags,
558            old_started_at,
559        )
560        .await
561        .unwrap()
562        .id
563    }
564
565    #[tokio::test]
566    async fn retention_tick_deletes_runs_past_the_cutoff_and_logs_nothing_fatal() {
567        let pool = connect_in_memory().await.unwrap();
568        let old_run_id = insert_old_run(&pool).await;
569
570        retention_tick(&pool, 30).await;
571
572        assert!(
573            bhtune_db::models::TuneRunRow::get(&pool, old_run_id)
574                .await
575                .unwrap()
576                .is_none()
577        );
578    }
579
580    #[tokio::test]
581    async fn retention_tick_on_a_pool_with_no_matching_runs_is_a_silent_no_op() {
582        let pool = connect_in_memory().await.unwrap();
583        // Nothing to delete, and no way for this to fail -- just confirms the helper
584        // returns cleanly rather than panicking on an empty database.
585        retention_tick(&pool, 30).await;
586    }
587
588    #[tokio::test]
589    async fn build_server_completes_startup_and_can_serve_until_shutdown() {
590        let dir = tempfile::tempdir().unwrap();
591        let config_path = dir.path().join("bhtune.toml");
592        let db_path = dir.path().join("bhtune.db");
593        let log_dir = dir.path().join("logs");
594        std::fs::write(
595            &config_path,
596            format!("db = {db_path:?}\nbind = \"127.0.0.1:0\"\n[log]\ndir = {log_dir:?}\n"),
597        )
598        .unwrap();
599
600        let server = build_server(Some(&config_path)).await.unwrap();
601        serve(server, async {}).await.unwrap();
602    }
603
604    #[tokio::test]
605    async fn serve_propagates_an_http_server_error() {
606        let error = serve_http(
607            async {
608                Err(std::io::Error::new(
609                    std::io::ErrorKind::BrokenPipe,
610                    "injected accept failure",
611                ))
612            },
613            ActiveRun::default(),
614        )
615        .await
616        .unwrap_err();
617
618        assert!(error.to_string().contains("injected accept failure"));
619    }
620
621    #[tokio::test]
622    async fn retention_tick_live_runs_when_retention_is_configured() {
623        let pool = connect_in_memory().await.unwrap();
624        let old_run_id = insert_old_run(&pool).await;
625        let store = Arc::new(RwLock::new(bhtune_runtime::config::LoadedConfigStore {
626            path: None,
627            missing_is_allowed: true,
628            original_raw: None,
629            config: bhtune_runtime::config::BhtuneConfig {
630                retention_days: Some(30),
631                ..Default::default()
632            },
633            revision: "revision".to_string(),
634            toml_allow_uncertain_quality: None,
635            toml_tuning: Default::default(),
636            tuning_sources: bhtune_runtime::config::tuning_config_sources(
637                &bhtune_runtime::config::TuningConfig::default(),
638            ),
639        }));
640
641        retention_tick_live(&pool, &store).await;
642
643        assert!(
644            bhtune_db::models::TuneRunRow::get(&pool, old_run_id)
645                .await
646                .unwrap()
647                .is_none()
648        );
649    }
650
651    #[tokio::test]
652    async fn retention_tick_live_skips_when_retention_is_disabled() {
653        let pool = connect_in_memory().await.unwrap();
654        let store = Arc::new(RwLock::new(bhtune_runtime::config::LoadedConfigStore {
655            path: None,
656            missing_is_allowed: true,
657            original_raw: None,
658            config: Default::default(),
659            revision: "revision".to_string(),
660            toml_allow_uncertain_quality: None,
661            toml_tuning: Default::default(),
662            tuning_sources: bhtune_runtime::config::tuning_config_sources(
663                &bhtune_runtime::config::TuningConfig::default(),
664            ),
665        }));
666
667        retention_tick_live(&pool, &store).await;
668    }
669
670    #[tokio::test]
671    async fn retention_tick_live_skips_when_config_store_lock_is_poisoned() {
672        let pool = connect_in_memory().await.unwrap();
673        let store = Arc::new(RwLock::new(bhtune_runtime::config::LoadedConfigStore {
674            path: None,
675            missing_is_allowed: true,
676            original_raw: None,
677            config: Default::default(),
678            revision: "revision".to_string(),
679            toml_allow_uncertain_quality: None,
680            toml_tuning: Default::default(),
681            tuning_sources: bhtune_runtime::config::tuning_config_sources(
682                &bhtune_runtime::config::TuningConfig::default(),
683            ),
684        }));
685        let poisoned = Arc::clone(&store);
686        std::thread::spawn(move || {
687            let _guard = poisoned.write().unwrap();
688            panic!("poison configuration store");
689        })
690        .join()
691        .unwrap_err();
692
693        retention_tick_live(&pool, &store).await;
694    }
695
696    #[tokio::test]
697    async fn retention_tick_live_logs_and_returns_when_sweep_fails() {
698        let pool = connect_in_memory().await.unwrap();
699        pool.close().await;
700        let store = Arc::new(RwLock::new(bhtune_runtime::config::LoadedConfigStore {
701            path: None,
702            missing_is_allowed: true,
703            original_raw: None,
704            config: bhtune_runtime::config::BhtuneConfig {
705                retention_days: Some(30),
706                ..Default::default()
707            },
708            revision: "revision".to_string(),
709            toml_allow_uncertain_quality: None,
710            toml_tuning: Default::default(),
711            tuning_sources: bhtune_runtime::config::tuning_config_sources(
712                &bhtune_runtime::config::TuningConfig::default(),
713            ),
714        }));
715
716        retention_tick_live(&pool, &store).await;
717    }
718
719    #[tokio::test]
720    async fn demo_cleanup_tick_succeeds_and_retries_database_failures() {
721        let state = crate::test_support::in_memory_state().await;
722        demo_cleanup_tick(&state).await;
723
724        state.pool.close().await;
725        demo_cleanup_tick(&state).await;
726    }
727
728    #[tokio::test(start_paused = true)]
729    async fn spawned_demo_cleanup_runs_on_the_policy_interval() {
730        tokio::time::resume();
731        let state = crate::test_support::in_memory_state().await;
732        tokio::time::pause();
733        spawn_demo_cleanup(state);
734        tokio::task::yield_now().await;
735        tokio::time::advance(Duration::from_secs(
736            bhtune_runtime::config::DEMO_CLEANUP_INTERVAL_SECS,
737        ))
738        .await;
739        tokio::time::resume();
740        tokio::task::yield_now().await;
741    }
742
743    #[tokio::test(start_paused = true)]
744    async fn spawned_retention_sweeper_runs_a_periodic_tick() {
745        tokio::time::resume();
746        let pool = connect_in_memory().await.unwrap();
747        let old_run_id = insert_old_run(&pool).await;
748        tokio::time::pause();
749        let store = Arc::new(RwLock::new(bhtune_runtime::config::LoadedConfigStore {
750            path: None,
751            missing_is_allowed: true,
752            original_raw: None,
753            config: bhtune_runtime::config::BhtuneConfig {
754                retention_days: Some(30),
755                ..Default::default()
756            },
757            revision: "revision".to_string(),
758            toml_allow_uncertain_quality: None,
759            toml_tuning: Default::default(),
760            tuning_sources: bhtune_runtime::config::tuning_config_sources(
761                &bhtune_runtime::config::TuningConfig::default(),
762            ),
763        }));
764
765        spawn_retention_sweeper(pool.clone(), store);
766        tokio::task::yield_now().await;
767        tokio::time::advance(RETENTION_SWEEP_INTERVAL + Duration::from_secs(1)).await;
768        tokio::time::resume();
769        for _ in 0..3 {
770            tokio::task::yield_now().await;
771        }
772
773        assert!(
774            bhtune_db::models::TuneRunRow::get(&pool, old_run_id)
775                .await
776                .unwrap()
777                .is_none()
778        );
779    }
780}