1use crate::types::ThreadCount;
9use anyhow::{Context, Result};
10
11const TOKIO_WORKER_THREAD_STACK_SIZE: usize = 8 * 1024 * 1024;
12
13#[derive(Debug, Clone)]
15pub struct RuntimeConfig {
16 worker_threads: usize,
18 enable_cpu_pinning: bool,
20}
21
22impl RuntimeConfig {
23 #[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 #[must_use]
40 pub const fn without_cpu_pinning(mut self) -> Self {
41 self.enable_cpu_pinning = false;
42 self
43 }
44
45 #[must_use]
47 pub const fn worker_threads(&self) -> usize {
48 self.worker_threads
49 }
50
51 #[must_use]
53 pub const fn is_single_threaded(&self) -> bool {
54 self.worker_threads == 1
55 }
56
57 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#[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
135pub 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
167pub 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#[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
208pub 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
229pub 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
247pub 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
263pub fn spawn_cache_stats_logger(proxy: &std::sync::Arc<crate::NntpProxy>) {
268 use std::sync::Arc;
269 use tracing::debug;
270
271 if !tracing::enabled!(tracing::Level::DEBUG) {
273 return;
274 }
275
276 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
312pub 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
374pub 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#[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
592pub 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#[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 let _ = shutdown_tx.send(()).await;
750
751 proxy.graceful_shutdown().await;
753 info!("Graceful shutdown complete");
754 });
755
756 shutdown_rx
757}
758
759pub 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 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#[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 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#[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
893pub 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
930pub 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
965pub 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
993pub 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
1023pub fn spawn_idle_connection_clearer(proxy: &std::sync::Arc<crate::NntpProxy>) {
1031 use std::sync::Arc;
1032 use std::time::Duration;
1033
1034 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 assert_eq!(config.worker_threads(), 1);
1061 assert!(config.is_single_threaded());
1062 assert!(config.enable_cpu_pinning); }
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 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 pin_to_cpu_cores(1);
1114 }
1115
1116 #[test]
1119 fn test_runtime_config_zero_threads_auto() {
1120 let config = RuntimeConfig::from_args(None);
1124 assert_eq!(config.worker_threads(), 1); }
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 #[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(); assert!(!config.enable_cpu_pinning);
1294 }
1295
1296 #[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 #[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 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 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 pin_to_cpu_cores(4);
1355 }
1356
1357 #[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 #[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 #[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 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 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}