Skip to main content

nntp_proxy/
runtime.rs

1//! Tokio runtime configuration and common utilities for binary targets
2//!
3//! This module provides:
4//! - Testable runtime configuration and builder logic
5//! - Shared utilities used across multiple binary targets to reduce duplication
6//! - Shutdown signal handling
7
8use crate::types::ThreadCount;
9use anyhow::{Context, Result};
10
11const TOKIO_WORKER_THREAD_STACK_SIZE: usize = 8 * 1024 * 1024;
12
13/// Runtime configuration
14#[derive(Debug, Clone)]
15pub struct RuntimeConfig {
16    /// Number of worker threads
17    worker_threads: usize,
18    /// Whether to enable CPU pinning (Linux only)
19    enable_cpu_pinning: bool,
20}
21
22impl RuntimeConfig {
23    /// Create runtime config from optional thread count
24    ///
25    /// If `threads` is None, defaults to 1 thread.
26    /// If `threads` is Some(ThreadCount(0)), uses number of CPU cores.
27    /// Single-threaded runtime is used if threads == 1.
28    #[must_use]
29    pub fn from_args(threads: Option<ThreadCount>) -> Self {
30        let worker_threads = threads.map_or(1, |t| t.get());
31
32        Self {
33            worker_threads,
34            enable_cpu_pinning: true,
35        }
36    }
37
38    /// Disable CPU pinning
39    #[must_use]
40    pub const fn without_cpu_pinning(mut self) -> Self {
41        self.enable_cpu_pinning = false;
42        self
43    }
44
45    /// Get number of worker threads
46    #[must_use]
47    pub const fn worker_threads(&self) -> usize {
48        self.worker_threads
49    }
50
51    /// Check if single-threaded
52    #[must_use]
53    pub const fn is_single_threaded(&self) -> bool {
54        self.worker_threads == 1
55    }
56
57    /// Build the tokio runtime
58    ///
59    /// Creates either a current-thread or multi-threaded runtime based on
60    /// the configured worker thread count. Applies CPU pinning if enabled.
61    ///
62    /// # Errors
63    /// Returns error if runtime creation fails or CPU pinning fails
64    pub fn build_runtime(self) -> Result<tokio::runtime::Runtime> {
65        let rt = if self.is_single_threaded() {
66            tracing::info!("Starting NNTP proxy with single-threaded runtime");
67            tokio::runtime::Builder::new_current_thread()
68                .enable_all()
69                .build()?
70        } else {
71            let num_cpus = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
72            tracing::info!(
73                "Starting NNTP proxy with {} worker threads (detected {} CPUs)",
74                self.worker_threads,
75                num_cpus
76            );
77            tokio::runtime::Builder::new_multi_thread()
78                .worker_threads(self.worker_threads)
79                .thread_stack_size(TOKIO_WORKER_THREAD_STACK_SIZE)
80                .enable_all()
81                .build()?
82        };
83
84        if self.enable_cpu_pinning {
85            pin_to_cpu_cores(self.worker_threads);
86        }
87
88        Ok(rt)
89    }
90}
91
92impl Default for RuntimeConfig {
93    fn default() -> Self {
94        Self::from_args(None)
95    }
96}
97
98/// Pin current process to specific CPU cores for optimal performance
99///
100/// This is a best-effort operation - failures are logged but not fatal.
101///
102/// # Arguments
103/// * `num_cores` - Number of CPU cores to pin to (`0..num_cores`)
104#[cfg(target_os = "linux")]
105fn pin_to_cpu_cores(num_cores: usize) {
106    use nix::sched::{CpuSet, sched_setaffinity};
107    use nix::unistd::Pid;
108
109    let mut cpu_set = CpuSet::new();
110    for core in 0..num_cores {
111        let _ = cpu_set.set(core);
112    }
113
114    match sched_setaffinity(Pid::from_raw(0), &cpu_set) {
115        Ok(()) => {
116            tracing::info!(
117                "Successfully pinned process to {} CPU cores for optimal performance",
118                num_cores
119            );
120        }
121        Err(e) => {
122            tracing::warn!(
123                "Failed to set CPU affinity: {}, continuing without pinning",
124                e
125            );
126        }
127    }
128}
129
130#[cfg(not(target_os = "linux"))]
131fn pin_to_cpu_cores(_num_cores: usize) {
132    tracing::info!("CPU pinning not available on this platform");
133}
134
135/// Wait for shutdown signal (Ctrl+C or SIGTERM on Unix)
136///
137/// This is a common utility for all binary targets. Handler installation
138/// failures are logged and treated as an immediate shutdown signal.
139pub async fn shutdown_signal() {
140    use tracing::warn;
141
142    let ctrl_c = async {
143        if let Err(error) = tokio::signal::ctrl_c().await {
144            warn!("Failed to install Ctrl+C handler: {error}");
145        }
146    };
147
148    #[cfg(unix)]
149    let terminate = async {
150        match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
151            Ok(mut signal) => {
152                signal.recv().await;
153            }
154            Err(error) => warn!("Failed to install SIGTERM handler: {error}"),
155        }
156    };
157
158    #[cfg(not(unix))]
159    let terminate = std::future::pending::<()>();
160
161    tokio::select! {
162        () = ctrl_c => {},
163        () = terminate => {},
164    }
165}
166
167// ============================================================================
168// Binary Utilities - Shared code for the unified nntp-proxy entrypoint.
169// ============================================================================
170
171/// Load configuration and log server information
172///
173/// Common pattern across all binary targets - load config and display backends.
174///
175/// # Errors
176/// Returns error if configuration loading fails
177pub fn load_and_log_config(
178    config_path: &str,
179) -> Result<(crate::config::Config, crate::config::ConfigSource)> {
180    use crate::load_config_with_fallback;
181    use tracing::info;
182
183    let (config, source) = load_config_with_fallback(config_path)?;
184
185    info!("Loaded configuration from {}", source.description());
186    info!("Loaded {} backend servers:", config.servers.len());
187    for server in &config.servers {
188        info!("  - {} ({}:{})", server.name, server.host, server.port);
189    }
190
191    Ok((config, source))
192}
193
194/// Extract listen address from CLI args or config
195///
196/// Prefers CLI args over config values.
197#[must_use]
198pub fn resolve_listen_address(
199    host_arg: Option<&str>,
200    port_arg: Option<crate::types::Port>,
201    config: &crate::config::Config,
202) -> (String, crate::types::Port) {
203    let host = host_arg.map_or_else(|| config.proxy.host.clone(), String::from);
204    let port = port_arg.unwrap_or(config.proxy.port);
205    (host, port)
206}
207
208/// Bind TCP listener and log startup information
209///
210/// # Errors
211/// Returns error if binding fails
212pub async fn bind_listener(
213    host: &str,
214    port: crate::types::Port,
215    routing_mode: crate::RoutingMode,
216) -> Result<tokio::net::TcpListener> {
217    use tracing::info;
218
219    let listen_addr = format!("{}:{}", host, port.get());
220    let listener = tokio::net::TcpListener::bind(&listen_addr)
221        .await
222        .with_context(|| format!("Failed to bind NNTP proxy listener at {listen_addr}"))?;
223
224    info!("NNTP proxy listening on {} ({})", listen_addr, routing_mode);
225
226    Ok(listener)
227}
228
229/// Spawn background task to prewarm connection pools
230///
231/// Returns immediately without blocking. Logs errors but doesn't fail.
232pub fn spawn_connection_prewarming(proxy: &std::sync::Arc<crate::NntpProxy>) {
233    use std::sync::Arc;
234    use tracing::{info, warn};
235
236    let proxy = Arc::clone(proxy);
237    tokio::spawn(async move {
238        info!("Prewarming connection pools...");
239        if let Err(e) = proxy.prewarm_connections().await {
240            warn!("Failed to prewarm connection pools: {}", e);
241            return;
242        }
243        info!("Connection pools ready");
244    });
245}
246
247/// Spawn background task to periodically flush connection stats
248///
249/// Flushes every 30 seconds to ensure metrics are up-to-date.
250pub fn spawn_stats_flusher(stats: &crate::metrics::ConnectionStatsAggregator) {
251    let stats = stats.clone();
252    tokio::spawn(async move {
253        let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
254        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
255
256        loop {
257            interval.tick().await;
258            stats.flush();
259        }
260    });
261}
262
263/// Spawn background task to periodically log cache statistics
264///
265/// Only spawns if cache is enabled AND debug logging is enabled.
266/// Logs every 60 seconds.
267pub fn spawn_cache_stats_logger(proxy: &std::sync::Arc<crate::NntpProxy>) {
268    use std::sync::Arc;
269    use tracing::debug;
270
271    // Only spawn if debug logging is enabled
272    if !tracing::enabled!(tracing::Level::DEBUG) {
273        return;
274    }
275
276    // Cache is always present now
277    let cache = Arc::clone(proxy.cache());
278    tokio::spawn(async move {
279        let mut interval =
280            tokio::time::interval(crate::constants::duration_polyfill::from_minutes(1));
281        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
282
283        loop {
284            interval.tick().await;
285            let entries = cache.entry_count();
286            let size_bytes = cache.weighted_size();
287            let hit_rate = cache.hit_rate();
288            debug!(
289                "Cache stats: entries={}, size={} ({:.1}% hit rate)",
290                entries,
291                crate::formatting::format_bytes(size_bytes),
292                hit_rate
293            );
294        }
295    });
296}
297
298fn response_write_metrics_interval() -> Option<std::time::Duration> {
299    let raw = std::env::var_os("NNTP_PROXY_RESPONSE_WRITE_METRICS_SECS")?;
300    let raw = raw.to_string_lossy();
301    let secs = raw.parse::<u64>().ok()?;
302    (secs > 0).then(|| std::time::Duration::from_secs(secs))
303}
304
305fn client_writer_lock_metrics_interval() -> Option<std::time::Duration> {
306    let raw = std::env::var_os("NNTP_PROXY_CLIENT_WRITER_LOCK_METRICS_SECS")?;
307    let raw = raw.to_string_lossy();
308    let secs = raw.parse::<u64>().ok()?;
309    (secs > 0).then(|| std::time::Duration::from_secs(secs))
310}
311
312/// Spawn background task to periodically log `ChunkedResponse::write_all_to` activity.
313///
314/// Enable this only for targeted profiling sessions:
315/// `NNTP_PROXY_RESPONSE_WRITE_METRICS_SECS=<seconds>`.
316pub fn spawn_response_write_metrics_logger() {
317    use tracing::info;
318
319    let Some(period) = response_write_metrics_interval() else {
320        return;
321    };
322
323    let mut previous = crate::pool::buffer::response_write_metrics_snapshot();
324
325    tokio::spawn(async move {
326        let mut interval = tokio::time::interval(period);
327        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
328
329        loop {
330            interval.tick().await;
331            let current = crate::pool::buffer::response_write_metrics_snapshot();
332            let responses_delta = current.responses.saturating_sub(previous.responses);
333            let chunks_delta = current
334                .chunks_written
335                .saturating_sub(previous.chunks_written);
336            let bytes_delta = current.bytes_written.saturating_sub(previous.bytes_written);
337            let tiny_chunks_delta = current.tiny_chunks.saturating_sub(previous.tiny_chunks);
338            let tiny_chunk_bytes_delta = current
339                .tiny_chunk_bytes
340                .saturating_sub(previous.tiny_chunk_bytes);
341            let small_chunks_delta = current.small_chunks.saturating_sub(previous.small_chunks);
342            let small_chunk_bytes_delta = current
343                .small_chunk_bytes
344                .saturating_sub(previous.small_chunk_bytes);
345            let avg_chunks_milli = chunks_delta
346                .saturating_mul(1000)
347                .checked_div(responses_delta)
348                .unwrap_or(0);
349
350            info!(
351                responses_delta = responses_delta,
352                single_chunk_delta = current
353                    .single_chunk_responses
354                    .saturating_sub(previous.single_chunk_responses),
355                multi_chunk_delta = current
356                    .multi_chunk_responses
357                    .saturating_sub(previous.multi_chunk_responses),
358                chunks_delta = chunks_delta,
359                bytes_delta = bytes_delta,
360                tiny_chunks_delta = tiny_chunks_delta,
361                tiny_chunk_bytes_delta = tiny_chunk_bytes_delta,
362                small_chunks_delta = small_chunks_delta,
363                small_chunk_bytes_delta = small_chunk_bytes_delta,
364                avg_chunks_milli = avg_chunks_milli,
365                max_chunks_seen = current.max_chunks_per_response,
366                "Response write metrics"
367            );
368
369            previous = current;
370        }
371    });
372}
373
374/// Spawn background task to periodically log client-writer mutex contention.
375///
376/// Enable this only for targeted profiling sessions:
377/// `NNTP_PROXY_CLIENT_WRITER_LOCK_METRICS_SECS=<seconds>`.
378pub fn spawn_client_writer_lock_metrics_logger() {
379    use tracing::info;
380
381    let Some(period) = client_writer_lock_metrics_interval() else {
382        return;
383    };
384
385    let mut previous = crate::session::shared_client_writer::client_writer_lock_metrics_snapshot();
386
387    tokio::spawn(async move {
388        let mut interval = tokio::time::interval(period);
389        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
390
391        loop {
392            interval.tick().await;
393            let current =
394                crate::session::shared_client_writer::client_writer_lock_metrics_snapshot();
395            let requests_delta = current.lock_requests.saturating_sub(previous.lock_requests);
396            let wait_nanos_delta = current.wait_nanos.saturating_sub(previous.wait_nanos);
397            let avg_wait_nanos = if current
398                .contended_locks
399                .saturating_sub(previous.contended_locks)
400                == 0
401            {
402                0
403            } else {
404                wait_nanos_delta
405                    / (current
406                        .contended_locks
407                        .saturating_sub(previous.contended_locks) as u64)
408            };
409
410            info!(
411                requests_delta = requests_delta,
412                immediate_delta = current
413                    .immediate_locks
414                    .saturating_sub(previous.immediate_locks),
415                contended_delta = current
416                    .contended_locks
417                    .saturating_sub(previous.contended_locks),
418                wait_nanos_delta = wait_nanos_delta,
419                avg_wait_nanos = avg_wait_nanos,
420                max_wait_nanos_seen = current.max_wait_nanos,
421                "Client writer lock metrics"
422            );
423
424            previous = current;
425        }
426    });
427}
428
429#[cfg(tokio_unstable)]
430#[derive(Clone, Copy, Debug)]
431struct TokioRuntimeMetricsSnapshot {
432    alive_tasks: usize,
433    workers: usize,
434    global_queue_depth: usize,
435    blocking_queue_depth: usize,
436    remote_schedule_count: u64,
437    budget_forced_yield_count: u64,
438    worker_local_schedule_count: u64,
439    worker_overflow_count: u64,
440    worker_steal_count: u64,
441    worker_steal_operations: u64,
442    worker_poll_count: u64,
443    worker_park_count: u64,
444    worker_noop_count: u64,
445    worker_busy_total_ns: u128,
446    worker_busy_min_ns: u128,
447    worker_busy_max_ns: u128,
448}
449
450#[cfg(tokio_unstable)]
451impl TokioRuntimeMetricsSnapshot {
452    fn capture(metrics: &tokio::runtime::RuntimeMetrics) -> Self {
453        let workers = metrics.num_workers();
454        let mut worker_local_schedule_count = 0u64;
455        let mut worker_overflow_count = 0u64;
456        let mut worker_steal_count = 0u64;
457        let mut worker_steal_operations = 0u64;
458        let mut worker_poll_count = 0u64;
459        let mut worker_park_count = 0u64;
460        let mut worker_noop_count = 0u64;
461        let mut worker_busy_total_ns = 0u128;
462        let mut worker_busy_min_ns = u128::MAX;
463        let mut worker_busy_max_ns = 0u128;
464
465        for worker in 0..workers {
466            worker_local_schedule_count += metrics.worker_local_schedule_count(worker);
467            worker_overflow_count += metrics.worker_overflow_count(worker);
468            worker_steal_count += metrics.worker_steal_count(worker);
469            worker_steal_operations += metrics.worker_steal_operations(worker);
470            worker_poll_count += metrics.worker_poll_count(worker);
471            worker_park_count += metrics.worker_park_count(worker);
472            worker_noop_count += metrics.worker_noop_count(worker);
473
474            let busy_ns = metrics.worker_total_busy_duration(worker).as_nanos();
475            worker_busy_total_ns += busy_ns;
476            worker_busy_min_ns = worker_busy_min_ns.min(busy_ns);
477            worker_busy_max_ns = worker_busy_max_ns.max(busy_ns);
478        }
479
480        Self {
481            alive_tasks: metrics.num_alive_tasks(),
482            workers,
483            global_queue_depth: metrics.global_queue_depth(),
484            blocking_queue_depth: metrics.blocking_queue_depth(),
485            remote_schedule_count: metrics.remote_schedule_count(),
486            budget_forced_yield_count: metrics.budget_forced_yield_count(),
487            worker_local_schedule_count,
488            worker_overflow_count,
489            worker_steal_count,
490            worker_steal_operations,
491            worker_poll_count,
492            worker_park_count,
493            worker_noop_count,
494            worker_busy_total_ns,
495            worker_busy_min_ns: if workers == 0 { 0 } else { worker_busy_min_ns },
496            worker_busy_max_ns,
497        }
498    }
499}
500
501#[cfg(tokio_unstable)]
502fn tokio_runtime_metrics_interval() -> Option<std::time::Duration> {
503    let raw = std::env::var_os("NNTP_PROXY_TOKIO_RUNTIME_METRICS_SECS")?;
504    let raw = raw.to_string_lossy();
505    let secs = raw.parse::<u64>().ok()?;
506    (secs > 0).then(|| std::time::Duration::from_secs(secs))
507}
508
509/// Spawn background task to periodically log Tokio runtime scheduler metrics.
510///
511/// Enable this only for targeted profiling sessions:
512/// `NNTP_PROXY_TOKIO_RUNTIME_METRICS_SECS=<seconds>`.
513#[cfg(tokio_unstable)]
514pub fn spawn_tokio_runtime_metrics_logger() {
515    use tracing::info;
516
517    let Some(period) = tokio_runtime_metrics_interval() else {
518        return;
519    };
520
521    let handle = tokio::runtime::Handle::current();
522    let mut previous = TokioRuntimeMetricsSnapshot::capture(&handle.metrics());
523
524    tokio::spawn(async move {
525        let mut interval = tokio::time::interval(period);
526        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
527
528        loop {
529            interval.tick().await;
530            let current = TokioRuntimeMetricsSnapshot::capture(&handle.metrics());
531
532            let busy_total_delta_ns = current
533                .worker_busy_total_ns
534                .saturating_sub(previous.worker_busy_total_ns);
535            let busy_min_delta_ns = current
536                .worker_busy_min_ns
537                .saturating_sub(previous.worker_busy_min_ns);
538            let busy_max_delta_ns = current
539                .worker_busy_max_ns
540                .saturating_sub(previous.worker_busy_max_ns);
541
542            info!(
543                workers = current.workers,
544                alive_tasks = current.alive_tasks,
545                global_queue_depth = current.global_queue_depth,
546                blocking_queue_depth = current.blocking_queue_depth,
547                remote_schedule_delta = current
548                    .remote_schedule_count
549                    .saturating_sub(previous.remote_schedule_count),
550                budget_forced_yield_delta = current
551                    .budget_forced_yield_count
552                    .saturating_sub(previous.budget_forced_yield_count),
553                worker_local_schedule_delta = current
554                    .worker_local_schedule_count
555                    .saturating_sub(previous.worker_local_schedule_count),
556                worker_overflow_delta = current
557                    .worker_overflow_count
558                    .saturating_sub(previous.worker_overflow_count),
559                worker_steal_delta = current
560                    .worker_steal_count
561                    .saturating_sub(previous.worker_steal_count),
562                worker_steal_ops_delta = current
563                    .worker_steal_operations
564                    .saturating_sub(previous.worker_steal_operations),
565                worker_poll_delta = current
566                    .worker_poll_count
567                    .saturating_sub(previous.worker_poll_count),
568                worker_park_delta = current
569                    .worker_park_count
570                    .saturating_sub(previous.worker_park_count),
571                worker_noop_delta = current
572                    .worker_noop_count
573                    .saturating_sub(previous.worker_noop_count),
574                worker_busy_total_ms_delta = busy_total_delta_ns / 1_000_000,
575                worker_busy_min_ms_delta = busy_min_delta_ns / 1_000_000,
576                worker_busy_max_ms_delta = busy_max_delta_ns / 1_000_000,
577                "Tokio runtime metrics"
578            );
579
580            previous = current;
581        }
582    });
583}
584
585#[cfg(not(tokio_unstable))]
586pub fn spawn_tokio_runtime_metrics_logger() {
587    if std::env::var_os("NNTP_PROXY_TOKIO_RUNTIME_METRICS_SECS").is_some() {
588        tracing::warn!("NNTP_PROXY_TOKIO_RUNTIME_METRICS_SECS requires a tokio_unstable build");
589    }
590}
591
592/// Persist runtime state that should survive process restarts.
593///
594/// Saves metrics unconditionally and availability state when the proxy uses
595/// the dedicated availability index.
596///
597/// # Errors
598/// Returns an error if metrics or availability persistence fails, but still
599/// attempts both writes so one failure does not suppress the other.
600pub async fn persist_runtime_state(
601    proxy: &std::sync::Arc<crate::NntpProxy>,
602    stats_path: std::path::PathBuf,
603    availability_path: Option<std::path::PathBuf>,
604    server_names: Vec<String>,
605) -> Result<()> {
606    let mut first_error = None;
607
608    let metrics = proxy.metrics().clone();
609    let metrics_path = stats_path.clone();
610    handle_shutdown_metrics_save_result(
611        &mut first_error,
612        &stats_path,
613        tokio::task::spawn_blocking(move || metrics.save_to_disk(&metrics_path, &server_names))
614            .await,
615    );
616
617    if let Some(path) = availability_path {
618        let cache = proxy.cache().clone();
619        let save_path = path.clone();
620        handle_shutdown_availability_save_result(
621            &mut first_error,
622            &path,
623            tokio::task::spawn_blocking(move || cache.save_to_disk(&save_path)).await,
624        );
625    }
626
627    if let Some(error) = first_error {
628        Err(error)
629    } else {
630        Ok(())
631    }
632}
633
634fn remember_first_persistence_error(slot: &mut Option<anyhow::Error>, error: anyhow::Error) {
635    if slot.is_none() {
636        *slot = Some(error);
637    }
638}
639
640fn handle_shutdown_metrics_save_result(
641    first_error: &mut Option<anyhow::Error>,
642    stats_path: &std::path::Path,
643    result: Result<Result<()>, tokio::task::JoinError>,
644) {
645    use tracing::{info, warn};
646
647    match result {
648        Ok(Ok(())) => info!("Metrics saved to {}", stats_path.display()),
649        Ok(Err(e)) => {
650            warn!("Failed to save metrics on shutdown: {}", e);
651            remember_first_persistence_error(first_error, e);
652        }
653        Err(e) => {
654            warn!("Failed to join metrics save task on shutdown: {}", e);
655            remember_first_persistence_error(first_error, e.into());
656        }
657    }
658}
659
660fn handle_shutdown_availability_save_result(
661    first_error: &mut Option<anyhow::Error>,
662    availability_path: &std::path::Path,
663    result: Result<Result<bool>, tokio::task::JoinError>,
664) {
665    use tracing::{info, warn};
666
667    match result {
668        Ok(Ok(true)) => info!(
669            "Availability index saved to {}",
670            availability_path.display()
671        ),
672        Ok(Ok(false)) => {}
673        Ok(Err(e)) => {
674            warn!("Failed to save availability index on shutdown: {}", e);
675            remember_first_persistence_error(first_error, e);
676        }
677        Err(e) => {
678            warn!("Failed to join availability save task on shutdown: {}", e);
679            remember_first_persistence_error(first_error, e.into());
680        }
681    }
682}
683
684fn handle_periodic_metrics_save_result(
685    stats_path: &std::path::Path,
686    result: Result<Result<()>, tokio::task::JoinError>,
687) {
688    use tracing::warn;
689
690    match result {
691        Ok(Ok(())) => {}
692        Ok(Err(e)) => warn!("Failed to save metrics to {}: {}", stats_path.display(), e),
693        Err(e) => warn!("Failed to save metrics to {}: {}", stats_path.display(), e),
694    }
695}
696
697fn handle_periodic_availability_save_result(
698    availability_path: &std::path::Path,
699    result: Result<Result<bool>, tokio::task::JoinError>,
700) {
701    use tracing::warn;
702
703    match result {
704        Ok(Ok(_)) => {}
705        Ok(Err(e)) => warn!(
706            "Failed to save availability index to {}: {}",
707            availability_path.display(),
708            e
709        ),
710        Err(e) => warn!(
711            "Failed to save availability index to {}: {}",
712            availability_path.display(),
713            e
714        ),
715    }
716}
717
718/// Spawn graceful shutdown handler
719///
720/// Waits for shutdown signal, then:
721/// 1. Sends shutdown notification via channel
722/// 2. Calls `graceful_shutdown()` on proxy
723///
724/// Returns the shutdown receiver channel.
725#[must_use]
726pub fn spawn_shutdown_handler(
727    proxy: &std::sync::Arc<crate::NntpProxy>,
728    stats_path: std::path::PathBuf,
729    availability_path: Option<std::path::PathBuf>,
730    server_names: Vec<String>,
731) -> tokio::sync::mpsc::Receiver<()> {
732    use std::sync::Arc;
733    use tracing::{info, warn};
734
735    let (shutdown_tx, shutdown_rx) = tokio::sync::mpsc::channel::<()>(1);
736    let proxy = Arc::clone(proxy);
737
738    tokio::spawn(async move {
739        shutdown_signal().await;
740        info!("Shutdown signal received");
741
742        if let Err(e) =
743            persist_runtime_state(&proxy, stats_path, availability_path, server_names).await
744        {
745            warn!("Failed to persist runtime state on shutdown: {}", e);
746        }
747
748        // Notify listeners
749        let _ = shutdown_tx.send(()).await;
750
751        // Close idle connections
752        proxy.graceful_shutdown().await;
753        info!("Graceful shutdown complete");
754    });
755
756    shutdown_rx
757}
758
759/// Run the main accept loop for client connections
760///
761/// Accepts connections and spawns a task for each based on routing mode.
762/// Exits when shutdown signal is received.
763///
764/// # Errors
765/// Returns error if `listener.accept()` fails
766pub async fn run_accept_loop(
767    proxy: std::sync::Arc<crate::NntpProxy>,
768    listener: tokio::net::TcpListener,
769    mut shutdown_rx: tokio::sync::mpsc::Receiver<()>,
770    routing_mode: crate::RoutingMode,
771) -> Result<()> {
772    use tracing::{error, info};
773
774    let uses_per_command = routing_mode.supports_per_command_routing();
775    let mut client_tasks = tokio::task::JoinSet::new();
776
777    loop {
778        tokio::select! {
779            biased;
780
781            _ = shutdown_rx.recv() => {
782                info!("Shutdown initiated, stopping accept loop");
783                break;
784            }
785
786            Some(join_result) = client_tasks.join_next(), if !client_tasks.is_empty() => {
787                if let Err(error) = join_result
788                    && !error.is_cancelled()
789                {
790                    error!("Client session task failed: {:?}", error);
791                }
792            }
793
794            accept_result = listener.accept() => {
795                let (stream, addr) = accept_result?;
796                let proxy = proxy.clone();
797
798                let client_task = async move {
799                    let result = if uses_per_command {
800                        proxy.handle_client_per_command_routing(stream, addr.into()).await
801                    } else {
802                        proxy.handle_client(stream, addr.into()).await
803                    };
804
805                    match result {
806                        // Normal completion or client disconnect — no action needed
807                        Ok(()) | Err(crate::session::SessionError::ClientDisconnect(_)) => {}
808                        Err(crate::session::SessionError::Backend(e)) => {
809                            error!("Error handling client {}: {:?}", addr, e);
810                        }
811                    }
812                };
813
814                client_tasks.spawn(client_task);
815            }
816        }
817    }
818
819    client_tasks.abort_all();
820    while let Some(join_result) = client_tasks.join_next().await {
821        if let Err(error) = join_result
822            && !error.is_cancelled()
823        {
824            error!("Client session task failed during shutdown: {:?}", error);
825        }
826    }
827
828    proxy.graceful_shutdown().await;
829    info!("Proxy shutdown complete");
830
831    Ok(())
832}
833
834// ============================================================================
835// Metrics Persistence Utilities
836// ============================================================================
837
838/// Resolve the metrics stats file path
839///
840/// If `stats_file` is configured, use it; otherwise default to "stats.json" alongside config file.
841///
842/// # Arguments
843/// * `config_path` - Path to the configuration file
844/// * `configured_path` - Optional configured path from config file
845#[must_use]
846pub fn resolve_stats_file_path(
847    config_path: &str,
848    configured_path: Option<&std::path::PathBuf>,
849) -> std::path::PathBuf {
850    use std::path::Path;
851
852    configured_path.map_or_else(
853        || {
854            // Default to stats.json alongside config file
855            let config_dir = Path::new(config_path)
856                .parent()
857                .unwrap_or_else(|| Path::new("."));
858            config_dir.join("stats.json")
859        },
860        std::clone::Clone::clone,
861    )
862}
863
864/// Resolve the persistence path for the availability index when body storage is disabled.
865///
866/// Returns None unless `store_article_bodies = false`. If `availability_index_path` is
867/// configured, use it; otherwise default to "availability.idx" alongside config.
868#[must_use]
869pub fn resolve_availability_file_path(
870    config_path: &str,
871    cache_config: Option<&crate::config::Cache>,
872) -> Option<std::path::PathBuf> {
873    use std::path::Path;
874
875    let cache_config = cache_config?;
876    if cache_config.store_article_bodies {
877        return None;
878    }
879
880    Some(
881        cache_config
882            .availability_index_path
883            .clone()
884            .unwrap_or_else(|| {
885                let config_dir = Path::new(config_path)
886                    .parent()
887                    .unwrap_or_else(|| Path::new("."));
888                config_dir.join("availability.idx")
889            }),
890    )
891}
892
893/// Load metrics from disk if the stats file exists
894///
895/// Returns None if file doesn't exist, is empty, or can't be parsed.
896/// Errors are logged but not fatal.
897///
898/// # Arguments
899/// * `stats_path` - Path to the stats JSON file
900/// * `server_names` - Current backend server names (for matching restored data)
901pub fn load_metrics_from_disk(
902    stats_path: &std::path::Path,
903    server_names: &[String],
904) -> Option<crate::metrics::MetricsStore> {
905    use tracing::{info, warn};
906
907    match crate::metrics::MetricsStore::load(stats_path, server_names) {
908        Ok(Some(store)) => {
909            info!("Restored metrics from {}", stats_path.display());
910            Some(store)
911        }
912        Ok(None) => {
913            info!(
914                "Stats file {} is empty or doesn't exist, starting fresh",
915                stats_path.display()
916            );
917            None
918        }
919        Err(e) => {
920            warn!(
921                "Failed to load stats from {}: {}, starting fresh",
922                stats_path.display(),
923                e
924            );
925            None
926        }
927    }
928}
929
930/// Load availability state from disk if the file exists.
931///
932/// Returns true when an availability-only cache was restored successfully.
933pub fn load_availability_from_disk(
934    cache: &std::sync::Arc<crate::cache::UnifiedCache>,
935    availability_path: &std::path::Path,
936) -> bool {
937    use tracing::{info, warn};
938
939    match cache.load_from_disk(availability_path) {
940        Ok(true) => {
941            info!(
942                "Restored availability index from {}",
943                availability_path.display()
944            );
945            true
946        }
947        Ok(false) => {
948            info!(
949                "Availability file {} is missing or not needed, starting fresh",
950                availability_path.display()
951            );
952            false
953        }
954        Err(e) => {
955            warn!(
956                "Failed to load availability index from {}: {}, starting fresh",
957                availability_path.display(),
958                e
959            );
960            false
961        }
962    }
963}
964
965/// Spawn background task to periodically save metrics to disk
966///
967/// Saves every 30 seconds. Logs errors but doesn't fail.
968pub fn spawn_metrics_saver(
969    proxy: &std::sync::Arc<crate::NntpProxy>,
970    stats_path: std::path::PathBuf,
971    server_names: Vec<String>,
972) {
973    use std::sync::Arc;
974
975    let proxy = Arc::clone(proxy);
976    tokio::spawn(async move {
977        let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
978        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
979        loop {
980            interval.tick().await;
981            let path = stats_path.clone();
982            let names = server_names.clone();
983            let metrics = proxy.metrics().clone();
984            let save_path = path.clone();
985            handle_periodic_metrics_save_result(
986                &stats_path,
987                tokio::task::spawn_blocking(move || metrics.save_to_disk(&save_path, &names)).await,
988            );
989        }
990    });
991}
992
993/// Spawn background task to periodically save availability state to disk.
994///
995/// Saves every 30 seconds when the proxy uses the dedicated availability index.
996pub fn spawn_availability_saver(
997    proxy: &std::sync::Arc<crate::NntpProxy>,
998    availability_path: Option<std::path::PathBuf>,
999) {
1000    use std::sync::Arc;
1001
1002    let Some(availability_path) = availability_path else {
1003        return;
1004    };
1005
1006    let proxy = Arc::clone(proxy);
1007    tokio::spawn(async move {
1008        let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
1009        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1010        loop {
1011            interval.tick().await;
1012            let cache = proxy.cache().clone();
1013            let path = availability_path.clone();
1014            let save_path = path.clone();
1015            handle_periodic_availability_save_result(
1016                &path,
1017                tokio::task::spawn_blocking(move || cache.save_to_disk(&save_path)).await,
1018            );
1019        }
1020    });
1021}
1022
1023/// Spawn background task that periodically checks for and clears idle backend connections.
1024///
1025/// Runs every 60 seconds and delegates to [`NntpProxy::check_and_clear_stale_pools`],
1026/// which checks each backend independently against its configured `backend_idle_timeout`.
1027///
1028/// This prevents zombie connections from accumulating during extended idle periods,
1029/// which was causing connection limit cascades in the observed 6-day failure.
1030pub fn spawn_idle_connection_clearer(proxy: &std::sync::Arc<crate::NntpProxy>) {
1031    use std::sync::Arc;
1032    use std::time::Duration;
1033
1034    /// How often to check for idle backends
1035    const CHECK_INTERVAL: Duration = crate::constants::duration_polyfill::from_minutes(1);
1036
1037    let proxy = Arc::clone(proxy);
1038
1039    tokio::spawn(async move {
1040        let mut interval = tokio::time::interval(CHECK_INTERVAL);
1041        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1042
1043        loop {
1044            interval.tick().await;
1045            proxy.check_and_clear_stale_pools();
1046        }
1047    });
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use super::*;
1053    use crate::types::Port;
1054
1055    #[test]
1056    fn test_runtime_config_from_args_default() {
1057        let config = RuntimeConfig::from_args(None);
1058
1059        // Should default to single-threaded (1 thread)
1060        assert_eq!(config.worker_threads(), 1);
1061        assert!(config.is_single_threaded());
1062        assert!(config.enable_cpu_pinning); // CPU pinning helps even with 1 thread
1063    }
1064
1065    #[test]
1066    fn test_runtime_config_from_args_explicit() {
1067        let thread_count = ThreadCount::new(4).unwrap();
1068        let config = RuntimeConfig::from_args(Some(thread_count));
1069
1070        assert_eq!(config.worker_threads(), 4);
1071        assert!(!config.is_single_threaded());
1072    }
1073
1074    #[test]
1075    fn test_runtime_config_single_threaded() {
1076        let thread_count = ThreadCount::new(1).unwrap();
1077        let config = RuntimeConfig::from_args(Some(thread_count));
1078
1079        assert_eq!(config.worker_threads(), 1);
1080        assert!(config.is_single_threaded());
1081    }
1082
1083    #[test]
1084    fn test_runtime_config_multi_threaded() {
1085        let thread_count = ThreadCount::new(8).unwrap();
1086        let config = RuntimeConfig::from_args(Some(thread_count));
1087
1088        assert_eq!(config.worker_threads(), 8);
1089        assert!(config.enable_cpu_pinning);
1090    }
1091
1092    #[test]
1093    fn test_runtime_config_without_cpu_pinning() {
1094        let thread_count = ThreadCount::new(4).unwrap();
1095        let config = RuntimeConfig::from_args(Some(thread_count)).without_cpu_pinning();
1096
1097        assert_eq!(config.worker_threads(), 4);
1098        assert!(!config.enable_cpu_pinning);
1099    }
1100
1101    #[test]
1102    fn test_runtime_config_default() {
1103        let config = RuntimeConfig::default();
1104
1105        // Should match from_args(None)
1106        let expected = RuntimeConfig::from_args(None);
1107        assert_eq!(config.worker_threads(), expected.worker_threads());
1108    }
1109
1110    #[test]
1111    fn test_pin_to_cpu_cores_non_fatal() {
1112        // Should not panic even if pinning fails
1113        pin_to_cpu_cores(1);
1114    }
1115
1116    // Edge case tests
1117
1118    #[test]
1119    fn test_runtime_config_zero_threads_auto() {
1120        // ThreadCount(0) means auto-detect CPUs
1121        // We can't test ThreadCount::new(0) directly as it returns None,
1122        // but we can test the from_args behavior
1123        let config = RuntimeConfig::from_args(None);
1124        assert_eq!(config.worker_threads(), 1); // Defaults to 1
1125    }
1126
1127    #[test]
1128    fn test_runtime_config_large_thread_count() {
1129        let thread_count = ThreadCount::new(128).unwrap();
1130        let config = RuntimeConfig::from_args(Some(thread_count));
1131
1132        assert_eq!(config.worker_threads(), 128);
1133        assert!(!config.is_single_threaded());
1134    }
1135
1136    #[test]
1137    fn test_runtime_config_clone() {
1138        let config = RuntimeConfig::from_args(Some(ThreadCount::new(4).unwrap()));
1139        let cloned = config.clone();
1140
1141        assert_eq!(cloned.worker_threads(), config.worker_threads());
1142        assert_eq!(cloned.enable_cpu_pinning, config.enable_cpu_pinning);
1143    }
1144
1145    #[test]
1146    fn test_runtime_config_debug() {
1147        let config = RuntimeConfig::from_args(Some(ThreadCount::new(2).unwrap()));
1148        let debug_str = format!("{config:?}");
1149
1150        assert!(debug_str.contains("RuntimeConfig"));
1151        assert!(debug_str.contains("worker_threads"));
1152        assert!(debug_str.contains("enable_cpu_pinning"));
1153    }
1154
1155    #[tokio::test]
1156    async fn test_bind_listener_context_mentions_proxy_listener() {
1157        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1158        let addr = listener.local_addr().unwrap();
1159
1160        let err = bind_listener(
1161            "127.0.0.1",
1162            Port::try_new(addr.port()).unwrap(),
1163            crate::RoutingMode::PerCommand,
1164        )
1165        .await
1166        .unwrap_err();
1167
1168        let err = format!("{err:#}");
1169        assert!(err.contains("Failed to bind NNTP proxy listener"));
1170    }
1171
1172    #[tokio::test]
1173    async fn test_accept_loop_aborts_active_clients_on_shutdown() {
1174        use std::sync::Arc;
1175        use tokio::io::AsyncReadExt;
1176
1177        let proxy = Arc::new(
1178            crate::NntpProxy::new_sync(
1179                crate::create_default_config(),
1180                crate::RoutingMode::PerCommand,
1181            )
1182            .unwrap(),
1183        );
1184        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1185        let addr = listener.local_addr().unwrap();
1186        let (shutdown_tx, shutdown_rx) = tokio::sync::mpsc::channel(1);
1187
1188        let accept_loop = tokio::spawn(run_accept_loop(
1189            proxy,
1190            listener,
1191            shutdown_rx,
1192            crate::RoutingMode::PerCommand,
1193        ));
1194
1195        let mut client = tokio::net::TcpStream::connect(addr).await.unwrap();
1196        let mut greeting = Vec::new();
1197        let mut byte = [0; 1];
1198        while !greeting.ends_with(b"\n") {
1199            client.read_exact(&mut byte).await.unwrap();
1200            greeting.push(byte[0]);
1201        }
1202        assert!(greeting.starts_with(b"200") || greeting.starts_with(b"201"));
1203
1204        shutdown_tx.send(()).await.unwrap();
1205        tokio::time::timeout(std::time::Duration::from_secs(3), accept_loop)
1206            .await
1207            .expect("accept loop should stop promptly after shutdown")
1208            .unwrap()
1209            .unwrap();
1210
1211        let read_result =
1212            tokio::time::timeout(std::time::Duration::from_secs(3), client.read(&mut byte))
1213                .await
1214                .expect("client socket should close when active session is aborted");
1215        assert!(matches!(read_result, Ok(0) | Err(_)));
1216    }
1217
1218    #[tokio::test]
1219    async fn test_accept_loop_aborts_all_active_clients_and_closes_listener() {
1220        use std::sync::Arc;
1221        use tokio::io::AsyncReadExt;
1222
1223        let proxy = Arc::new(
1224            crate::NntpProxy::new_sync(
1225                crate::create_default_config(),
1226                crate::RoutingMode::PerCommand,
1227            )
1228            .unwrap(),
1229        );
1230        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1231        let addr = listener.local_addr().unwrap();
1232        let (shutdown_tx, shutdown_rx) = tokio::sync::mpsc::channel(1);
1233
1234        let accept_loop = tokio::spawn(run_accept_loop(
1235            proxy,
1236            listener,
1237            shutdown_rx,
1238            crate::RoutingMode::PerCommand,
1239        ));
1240
1241        let mut clients = Vec::new();
1242        for _ in 0..3 {
1243            let mut client = tokio::net::TcpStream::connect(addr).await.unwrap();
1244            let mut greeting = Vec::new();
1245            let mut byte = [0; 1];
1246            while !greeting.ends_with(b"\n") {
1247                client.read_exact(&mut byte).await.unwrap();
1248                greeting.push(byte[0]);
1249            }
1250            assert!(greeting.starts_with(b"200") || greeting.starts_with(b"201"));
1251            clients.push(client);
1252        }
1253
1254        shutdown_tx.send(()).await.unwrap();
1255        tokio::time::timeout(std::time::Duration::from_secs(3), accept_loop)
1256            .await
1257            .expect("accept loop should stop promptly after shutdown")
1258            .unwrap()
1259            .unwrap();
1260
1261        assert!(
1262            tokio::net::TcpStream::connect(addr).await.is_err(),
1263            "listener should be closed after accept-loop shutdown"
1264        );
1265
1266        for mut client in clients {
1267            let mut byte = [0; 1];
1268            let read_result =
1269                tokio::time::timeout(std::time::Duration::from_secs(3), client.read(&mut byte))
1270                    .await
1271                    .expect("client socket should close when session is aborted");
1272            assert!(matches!(read_result, Ok(0) | Err(_)));
1273        }
1274    }
1275
1276    // Builder pattern tests
1277
1278    #[test]
1279    fn test_runtime_config_builder_chaining() {
1280        let config =
1281            RuntimeConfig::from_args(Some(ThreadCount::new(4).unwrap())).without_cpu_pinning();
1282
1283        assert_eq!(config.worker_threads(), 4);
1284        assert!(!config.enable_cpu_pinning);
1285    }
1286
1287    #[test]
1288    fn test_runtime_config_builder_multiple_calls() {
1289        let config = RuntimeConfig::from_args(Some(ThreadCount::new(8).unwrap()))
1290            .without_cpu_pinning()
1291            .without_cpu_pinning(); // Idempotent
1292
1293        assert!(!config.enable_cpu_pinning);
1294    }
1295
1296    // Getter tests
1297
1298    #[test]
1299    fn test_worker_threads_getter() {
1300        let config = RuntimeConfig::from_args(Some(ThreadCount::new(6).unwrap()));
1301        assert_eq!(config.worker_threads(), 6);
1302    }
1303
1304    #[test]
1305    fn test_is_single_threaded_true() {
1306        let config = RuntimeConfig::from_args(Some(ThreadCount::new(1).unwrap()));
1307        assert!(config.is_single_threaded());
1308    }
1309
1310    #[test]
1311    fn test_is_single_threaded_false() {
1312        let config = RuntimeConfig::from_args(Some(ThreadCount::new(2).unwrap()));
1313        assert!(!config.is_single_threaded());
1314    }
1315
1316    #[test]
1317    fn test_is_single_threaded_false_for_large() {
1318        let config = RuntimeConfig::from_args(Some(ThreadCount::new(100).unwrap()));
1319        assert!(!config.is_single_threaded());
1320    }
1321
1322    // CPU pinning platform-specific tests
1323
1324    #[test]
1325    #[cfg(target_os = "linux")]
1326    fn test_pin_to_cpu_cores_linux_single_core() {
1327        pin_to_cpu_cores(1);
1328    }
1329
1330    #[test]
1331    #[cfg(target_os = "linux")]
1332    fn test_pin_to_cpu_cores_linux_multi_core() {
1333        pin_to_cpu_cores(4);
1334    }
1335
1336    #[test]
1337    #[cfg(target_os = "linux")]
1338    fn test_pin_to_cpu_cores_linux_zero_cores() {
1339        // Edge case: 0 cores should still succeed (no-op)
1340        pin_to_cpu_cores(0);
1341    }
1342
1343    #[test]
1344    #[cfg(target_os = "linux")]
1345    fn test_pin_to_cpu_cores_linux_many_cores() {
1346        // Try to pin to more cores than available - should not panic
1347        pin_to_cpu_cores(1024);
1348    }
1349
1350    #[test]
1351    #[cfg(not(target_os = "linux"))]
1352    fn test_pin_to_cpu_cores_non_linux() {
1353        // Non-Linux platforms should gracefully no-op
1354        pin_to_cpu_cores(4);
1355    }
1356
1357    // Default implementation tests
1358
1359    #[test]
1360    fn test_default_matches_from_args_none() {
1361        let default_config = RuntimeConfig::default();
1362        let explicit_config = RuntimeConfig::from_args(None);
1363
1364        assert_eq!(
1365            default_config.worker_threads(),
1366            explicit_config.worker_threads()
1367        );
1368        assert_eq!(
1369            default_config.enable_cpu_pinning,
1370            explicit_config.enable_cpu_pinning
1371        );
1372    }
1373
1374    #[test]
1375    fn test_default_is_single_threaded() {
1376        let config = RuntimeConfig::default();
1377        assert!(config.is_single_threaded());
1378    }
1379
1380    #[test]
1381    fn test_default_has_cpu_pinning_enabled() {
1382        let config = RuntimeConfig::default();
1383        assert!(config.enable_cpu_pinning);
1384    }
1385
1386    // Configuration combination tests
1387
1388    #[test]
1389    fn test_config_single_threaded_with_pinning() {
1390        let config = RuntimeConfig::from_args(Some(ThreadCount::new(1).unwrap()));
1391        assert!(config.is_single_threaded());
1392        assert!(config.enable_cpu_pinning);
1393    }
1394
1395    #[test]
1396    fn test_config_single_threaded_without_pinning() {
1397        let config =
1398            RuntimeConfig::from_args(Some(ThreadCount::new(1).unwrap())).without_cpu_pinning();
1399        assert!(config.is_single_threaded());
1400        assert!(!config.enable_cpu_pinning);
1401    }
1402
1403    #[test]
1404    fn test_config_multi_threaded_with_pinning() {
1405        let config = RuntimeConfig::from_args(Some(ThreadCount::new(4).unwrap()));
1406        assert!(!config.is_single_threaded());
1407        assert!(config.enable_cpu_pinning);
1408    }
1409
1410    #[test]
1411    fn test_config_multi_threaded_without_pinning() {
1412        let config =
1413            RuntimeConfig::from_args(Some(ThreadCount::new(4).unwrap())).without_cpu_pinning();
1414        assert!(!config.is_single_threaded());
1415        assert!(!config.enable_cpu_pinning);
1416    }
1417
1418    // ========================================================================
1419    // Metrics Persistence Tests
1420    // ========================================================================
1421
1422    #[test]
1423    fn test_resolve_stats_file_path_with_configured() {
1424        use std::path::PathBuf;
1425
1426        let configured = PathBuf::from("/custom/path/stats.json");
1427        let result = resolve_stats_file_path("config.toml", Some(&configured));
1428
1429        assert_eq!(result, configured);
1430    }
1431
1432    #[test]
1433    fn test_resolve_stats_file_path_default() {
1434        let result = resolve_stats_file_path("/etc/nntp-proxy/config.toml", None);
1435
1436        // Should be /etc/nntp-proxy/stats.json
1437        assert_eq!(result.file_name().unwrap(), "stats.json");
1438        assert!(result.to_string_lossy().contains("nntp-proxy"));
1439    }
1440
1441    #[test]
1442    fn test_resolve_stats_file_path_bare_filename() {
1443        let result = resolve_stats_file_path("config.toml", None);
1444
1445        // Bare filename should resolve to ./stats.json
1446        assert_eq!(result.file_name().unwrap(), "stats.json");
1447    }
1448
1449    #[test]
1450    fn test_load_metrics_from_disk_missing_file() {
1451        use std::path::Path;
1452
1453        let nonexistent_path = Path::new("/tmp/this-does-not-exist-12345.json");
1454        let result = load_metrics_from_disk(nonexistent_path, &[]);
1455
1456        assert!(result.is_none(), "Missing file should return None");
1457    }
1458
1459    #[test]
1460    fn test_resolve_availability_file_path_none_for_full_cache() {
1461        let cache = crate::config::Cache {
1462            store_article_bodies: true,
1463            ..Default::default()
1464        };
1465        assert!(resolve_availability_file_path("config.toml", Some(&cache)).is_none());
1466    }
1467
1468    #[test]
1469    fn test_resolve_availability_file_path_none_without_cache_config() {
1470        assert!(resolve_availability_file_path("config.toml", None).is_none());
1471    }
1472
1473    #[test]
1474    fn test_resolve_availability_file_path_default_for_availability_only() {
1475        let cache = crate::config::Cache {
1476            store_article_bodies: false,
1477            ..Default::default()
1478        };
1479
1480        let result = resolve_availability_file_path("/etc/nntp-proxy/config.toml", Some(&cache))
1481            .expect("availability-only mode should resolve a path");
1482
1483        assert_eq!(result.file_name().unwrap(), "availability.idx");
1484        assert!(result.to_string_lossy().contains("nntp-proxy"));
1485    }
1486
1487    #[test]
1488    fn test_resolve_availability_file_path_with_configured() {
1489        let cache = crate::config::Cache {
1490            store_article_bodies: false,
1491            availability_index_path: Some(std::path::PathBuf::from(
1492                "/custom/path/availability.idx",
1493            )),
1494            ..Default::default()
1495        };
1496
1497        let result = resolve_availability_file_path("config.toml", Some(&cache))
1498            .expect("configured path should be used");
1499
1500        assert_eq!(result, cache.availability_index_path.unwrap());
1501    }
1502}