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 two different ways (`server-windows-service`): directly, from an interactive
3//! console session or a systemd/launchd-managed 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_cli::{config, db, logging};
19
20use crate::active_run::ActiveRun;
21use crate::{AppState, build_router};
22
23/// How long graceful shutdown waits for an in-flight tune run to actually finish cancelling
24/// (its restore attempt included) after `axum::serve` itself has finished draining
25/// in-flight HTTP connections, before giving up and exiting anyway -- see
26/// [`ActiveRun::cancel_and_wait`]'s own doc comment for what "giving up" logs.
27const SHUTDOWN_RUN_CANCEL_TIMEOUT: Duration = Duration::from_secs(35);
28
29/// How often the server re-applies `history-retention`'s policy for as long as it keeps
30/// running, on top of the one-shot sweep `db::open` already ran at startup. A day is far
31/// more than frequent enough for an age-based-in-days policy -- the oldest a run can ever
32/// linger past its cutoff is one interval -- while being infrequent enough that the sweep
33/// never meaningfully competes with real traffic for the database.
34const RETENTION_SWEEP_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
35
36/// A `bhtune-server` that has finished every startup step through binding its listening
37/// socket -- config/log/db resolution, migrations, template/retention seeding -- and is
38/// ready to actually start accepting connections, but has not yet been handed to
39/// [`serve`]. Returned as one value (rather than `serve` doing all of this itself) so a
40/// caller can observe "fully bound and ready" as a distinct moment from "now serving until
41/// told to stop" -- see this module's doc comment.
42pub struct BoundServer {
43    listener: tokio::net::TcpListener,
44    app: axum::Router,
45    active_run: ActiveRun,
46    // Held for as long as `BoundServer` (and, transitively, whatever `serve` destructures it
47    // into) lives -- dropping it any earlier risks silently truncating buffered log lines,
48    // per `logging::init_tracing`'s own doc comment. Not unwrapped, matching the original
49    // `main.rs`'s own `let _log_guard = logging::init_tracing(..)` -- a logging setup failure
50    // (e.g. an unwritable log directory) shouldn't itself prevent the server from starting.
51    log_guard: anyhow::Result<tracing_appender::non_blocking::WorkerGuard>,
52}
53
54/// Runs every step of `bhtune-server`'s startup through binding its listening socket:
55/// resolve config (an explicit `config_path` if given, otherwise the platform's
56/// auto-discovered path -- mirroring `bhtune-cli` calling `load_config(None)` whenever
57/// `--config` itself wasn't passed), init logging, open/migrate/seed the database, spawn the
58/// periodic retention sweeper, and bind the configured address.
59///
60/// Does not start serving -- see [`serve`].
61pub 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    // Logs the OS-assigned address, not the requested `addr` -- identical for every real
157    // deployment (a concrete port is always configured), but the two differ whenever the
158    // requested port is `0` (bind to any free port), which is exactly what lets tests avoid
159    // hardcoding a port that might collide with something else already listening.
160    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
172/// Serves `server` until `shutdown` resolves, then drains in-flight HTTP connections and
173/// cancels/waits for any still-active tune run before returning -- the interactive path's
174/// `main.rs` awaits [`shutdown_signal`]; the Windows service path
175/// (`crate::service::windows_impl`) awaits its own SCM-driven signal instead.
176pub async fn serve(
177    server: BoundServer,
178    shutdown: impl Future<Output = ()> + Send + 'static,
179) -> anyhow::Result<()> {
180    // `_log_guard` is never read again, only held: it must simply outlive every `tracing`
181    // call this function makes below, which binding it (rather than discarding it with `_`
182    // in the destructuring pattern, which would drop it immediately) guarantees -- it goes
183    // out of scope, and only then flushes/joins its writer thread, when this function
184    // returns.
185    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    // Runs *after* axum has finished draining in-flight HTTP connections, not folded into
209    // the shutdown future itself -- so a client mid-`GET /api/runs/:id` during shutdown still
210    // gets its response before this starts cancelling the run it might have been asking
211    // about.
212    active_run
213        .cancel_and_wait(SHUTDOWN_RUN_CANCEL_TIMEOUT)
214        .await;
215
216    Ok(())
217}
218
219/// Waits for Ctrl+C (SIGINT), or on Unix, SIGTERM -- so a service manager's ordinary "stop"
220/// request (`systemctl stop`, `launchctl stop`) drains in-flight requests the same way an
221/// interactive Ctrl+C does, rather than dropping connections mid-response. The Windows
222/// Service Control Manager's own Stop/Shutdown control codes don't arrive as either of these
223/// signals -- see `crate::service::windows_impl::run_service` for the SCM-specific
224/// equivalent used instead when running as a Windows service.
225pub 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
249/// Spawns the background task that re-applies `history-retention`'s policy every
250/// [`RETENTION_SWEEP_INTERVAL`] for as long as the server keeps running. The task retains the
251/// synchronized store rather than a copied day count, so a config-page save is observed by
252/// the next sweep; a disabled policy simply makes that tick a no-op.
253///
254/// Not joined or cancelled anywhere: the task only ever does one cheap `DELETE` per tick and
255/// holds no resources between ticks, so letting it end abruptly when the process exits
256/// (rather than folding it into a careful graceful-shutdown sequence) risks losing at most
257/// one in-progress sweep, never corrupting anything -- SQLite's own transaction guarantees
258/// cover the rest.
259fn 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        // The first tick fires immediately; `db::open` already ran a startup sweep moments
266        // ago, so this first iteration would otherwise be a guaranteed-redundant no-op.
267        interval.tick().await;
268        loop {
269            interval.tick().await;
270            retention_tick_live(&pool, &config_store).await;
271        }
272    });
273}
274
275/// Reaps expired anonymous Demo sessions and trims each visitor's terminal history while the
276/// server remains alive. Demo history is bounded independently of the Full-mode age-retention
277/// policy, so it needs its own maintenance loop.
278fn 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
325/// One periodic retention sweep. Logs a warning and returns on failure rather than
326/// propagating -- unlike `db::open`'s startup sweep (fatal by design, since a one-shot CLI
327/// invocation failing fast beats silently proceeding on what might be a broken database),
328/// crashing a long-running server over a background maintenance hiccup would drop every
329/// in-flight HTTP connection and any actively-running tune, a far worse outcome than
330/// skipping one sweep and retrying at the next interval.
331async 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        // Nothing to delete, and no way for this to fail -- just confirms the helper
512        // returns cleanly rather than panicking on an empty database.
513        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}