From 61b33cdfa6a3194c627c087888431877bf9185f1 Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:48:22 +0100 Subject: [PATCH 01/10] refactor: centralize defaults and unify ServerConfig conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a single defaults module as the source of truth for tuning constants, replacing scattered definitions and inverted config→events imports. Event processor configs now convert directly from ServerConfig via from_server_config. Co-authored-by: Cursor --- src/config.rs | 25 ++---- src/crypto/nip59.rs | 3 +- src/defaults.rs | 47 ++++++++++++ src/main.rs | 153 +------------------------------------ src/nostr/client.rs | 7 +- src/nostr/events.rs | 180 +++++++++++++++++++++++++++++++++++++------- src/rate_limiter.rs | 28 +------ 7 files changed, 213 insertions(+), 230 deletions(-) create mode 100644 src/defaults.rs diff --git a/src/config.rs b/src/config.rs index 1b1358c..1ddc922 100644 --- a/src/config.rs +++ b/src/config.rs @@ -39,17 +39,15 @@ use tokio::sync::Semaphore; use tracing::debug; use zeroize::Zeroizing; -use crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT; -use crate::error::Result; -use crate::nostr::events::{ - DEFAULT_DEDUP_RETENTION_SECS, DEFAULT_MAX_NOTIFICATION_AGE_SECS, +use crate::defaults::{ + DEFAULT_DEDUP_RETENTION_SECS, DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_HOUR, + DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_MINUTE, DEFAULT_MAX_CONCURRENT_EVENT_PROCESSING, + DEFAULT_MAX_DEDUP_CACHE_SIZE, DEFAULT_MAX_NOTIFICATION_AGE_SECS, DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, + DEFAULT_MAX_SIZE as DEFAULT_MAX_RATE_LIMIT_CACHE_SIZE, DEFAULT_MAX_TOKENS_PER_EVENT, + DEFAULT_RATE_LIMIT_PER_HOUR, DEFAULT_RATE_LIMIT_PER_MINUTE, }; -use crate::rate_limiter::{ - DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_HOUR, DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_MINUTE, - DEFAULT_MAX_SIZE as DEFAULT_MAX_RATE_LIMIT_CACHE_SIZE, DEFAULT_RATE_LIMIT_PER_HOUR, - DEFAULT_RATE_LIMIT_PER_MINUTE, -}; +use crate::error::Result; /// Root configuration structure. #[derive(Debug, Clone, Deserialize)] @@ -79,18 +77,9 @@ pub struct AppConfig { pub glitchtip: GlitchtipConfig, } -/// Default maximum size for the deduplication cache. -const DEFAULT_MAX_DEDUP_CACHE_SIZE: usize = 100_000; const DEFAULT_HEALTH_BIND_ADDRESS: &str = "127.0.0.1:8080"; const ENV_PREFIX: &str = "TRANSPONDER_"; -/// Default maximum number of events processed concurrently. -/// -/// Caps the total in-flight gift-wrap unwrap (ECDH) work so a flood cannot -/// spawn unbounded crypto tasks, while still draining the relay broadcast -/// channel quickly enough to avoid `Lagged` overflow. -const DEFAULT_MAX_CONCURRENT_EVENT_PROCESSING: usize = 64; - fn default_max_dedup_cache_size() -> usize { DEFAULT_MAX_DEDUP_CACHE_SIZE } diff --git a/src/crypto/nip59.rs b/src/crypto/nip59.rs index a1f1812..2b896ef 100644 --- a/src/crypto/nip59.rs +++ b/src/crypto/nip59.rs @@ -16,8 +16,7 @@ const TAG_ENCODING: &str = "encoding"; const VERSION_MIP05_V1: &str = "mip05-v1"; const ENCODING_BASE64: &str = "base64"; -/// Default maximum number of encrypted tokens accepted in one notification event. -pub const DEFAULT_MAX_TOKENS_PER_EVENT: usize = 100; +pub use crate::defaults::DEFAULT_MAX_TOKENS_PER_EVENT; /// Maximum number of characters of attacker-controlled tag content included in /// error messages. diff --git a/src/defaults.rs b/src/defaults.rs new file mode 100644 index 0000000..ad87d94 --- /dev/null +++ b/src/defaults.rs @@ -0,0 +1,47 @@ +//! Centralized default values for configuration and runtime components. +//! +//! Single source of truth for serde defaults in [`crate::config`] and for +//! runtime components that share the same tuning knobs. + +use nostr_sdk::prelude::nip59; + +/// Maximum NIP-59 timestamp randomization window for gift wraps (seconds). +pub const NIP59_TIMESTAMP_TWEAK_WINDOW_SECS: u64 = nip59::RANGE_RANDOM_TIMESTAMP_TWEAK.end; + +// === Rate limiting === + +/// Default maximum cache size (100,000 entries). +pub const DEFAULT_MAX_SIZE: usize = 100_000; + +/// Default rate limit per minute (240 = 4 per second). +pub const DEFAULT_RATE_LIMIT_PER_MINUTE: u32 = 240; + +/// Default rate limit per hour. +pub const DEFAULT_RATE_LIMIT_PER_HOUR: u32 = 5000; + +/// Default global pre-unwrap admission limit per minute. +pub const DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_MINUTE: u32 = 600; + +/// Default global pre-unwrap admission limit per hour. +pub const DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_HOUR: u32 = 30_000; + +// === Event processing / replay protection === + +/// Default maximum size for the volatile deduplication cache. +pub const DEFAULT_MAX_DEDUP_CACHE_SIZE: usize = 100_000; + +/// Default maximum age for the unwrapped kind:446 notification request rumor. +pub const DEFAULT_MAX_NOTIFICATION_AGE_SECS: u64 = 3_600; + +/// Default tolerated clock skew for future-dated notification rumors. +pub const DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS: u64 = 300; + +/// Default duration to keep processed gift-wrap event IDs in replay state. +pub const DEFAULT_DEDUP_RETENTION_SECS: u64 = + NIP59_TIMESTAMP_TWEAK_WINDOW_SECS + DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS; + +/// Default maximum number of encrypted tokens accepted in one notification event. +pub const DEFAULT_MAX_TOKENS_PER_EVENT: usize = 100; + +/// Default maximum number of events processed concurrently. +pub const DEFAULT_MAX_CONCURRENT_EVENT_PROCESSING: usize = 64; diff --git a/src/main.rs b/src/main.rs index d43eee2..dc4d8fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,6 @@ //! to APNs and FCM. use std::io::Write; -use std::num::NonZeroUsize; use std::sync::Arc; use std::time::Duration; use std::{ @@ -26,6 +25,7 @@ use zeroize::Zeroizing; mod config; mod crypto; +mod defaults; mod error; mod metrics; mod nostr; @@ -135,56 +135,6 @@ fn validate_startup_config(server_private_key: &str, relays: &config::RelayConfi Ok(()) } -/// Convert a size field that `AppConfig::validate` already rejected as zero. -/// -/// The downstream cache/limiter configs take [`NonZeroUsize`] so the old -/// silent zero-to-default constructor coercions are unrepresentable; this is -/// the single, loud conversion point from the load-validated `usize` fields. -fn validated_non_zero(value: usize, field: &str) -> NonZeroUsize { - NonZeroUsize::new(value) - .unwrap_or_else(|| panic!("{field} is validated as non-zero at config load")) -} - -fn build_rate_limit_config(server: &config::ServerConfig) -> nostr::events::TokenRateLimitConfig { - nostr::events::TokenRateLimitConfig { - max_cache_size: validated_non_zero( - server.max_rate_limit_cache_size, - "server.max_rate_limit_cache_size", - ), - max_tokens_per_event: validated_non_zero( - server.max_tokens_per_event, - "server.max_tokens_per_event", - ), - encrypted_token_per_minute: server.encrypted_token_rate_limit_per_minute, - encrypted_token_per_hour: server.encrypted_token_rate_limit_per_hour, - device_token_per_minute: server.device_token_rate_limit_per_minute, - device_token_per_hour: server.device_token_rate_limit_per_hour, - global_unwrap_per_minute: server.global_unwrap_rate_limit_per_minute, - global_unwrap_per_hour: server.global_unwrap_rate_limit_per_hour, - } -} - -fn build_replay_protection_config( - server: &config::ServerConfig, -) -> nostr::events::ReplayProtectionConfig { - let dedup_state_path = if server.dedup_state_path.as_os_str().is_empty() { - None - } else { - Some(server.dedup_state_path.clone()) - }; - - nostr::events::ReplayProtectionConfig { - max_dedup_cache_size: validated_non_zero( - server.max_dedup_cache_size, - "server.max_dedup_cache_size", - ), - dedup_state_path, - dedup_retention: Duration::from_secs(server.dedup_retention_secs), - max_notification_age: Duration::from_secs(server.max_notification_age_secs), - max_notification_future_skew: Duration::from_secs(server.max_notification_future_skew_secs), - } -} - fn parse_server_secret_key(server_private_key: &str) -> Result { SecretKey::parse(server_private_key).context("Invalid server private key") } @@ -587,8 +537,8 @@ async fn run(mut config: AppConfig) -> Result<()> { // limiting. Constructed before any relay network work so the event // consumer can start polling the moment the notification receiver exists // (see below). - let rate_limit_config = build_rate_limit_config(&config.server); - let replay_config = build_replay_protection_config(&config.server); + let rate_limit_config = nostr::events::TokenRateLimitConfig::from_server_config(&config.server); + let replay_config = nostr::events::ReplayProtectionConfig::from_server_config(&config.server); let event_processor = Arc::new( EventProcessor::with_replay_config( nip59_handler, @@ -1886,103 +1836,6 @@ mod tests { assert_eq!(parsed_key.to_bech32().unwrap(), secret_key); } - #[test] - fn build_rate_limit_config_matches_server_settings() { - let server = config::ServerConfig { - private_key: Zeroizing::new(String::new()), - private_key_file: String::new(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 100_000, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, - max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, - max_notification_future_skew_secs: - crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, - max_rate_limit_cache_size: 1234, - max_tokens_per_event: 25, - encrypted_token_rate_limit_per_minute: 111, - encrypted_token_rate_limit_per_hour: 2222, - device_token_rate_limit_per_minute: 333, - device_token_rate_limit_per_hour: 4444, - max_concurrent_event_processing: 7, - global_unwrap_rate_limit_per_minute: 555, - global_unwrap_rate_limit_per_hour: 6666, - }; - - let rate_limit_config = build_rate_limit_config(&server); - - assert_eq!(rate_limit_config.max_cache_size.get(), 1234); - assert_eq!(rate_limit_config.max_tokens_per_event.get(), 25); - assert_eq!(rate_limit_config.encrypted_token_per_minute, 111); - assert_eq!(rate_limit_config.encrypted_token_per_hour, 2222); - assert_eq!(rate_limit_config.device_token_per_minute, 333); - assert_eq!(rate_limit_config.device_token_per_hour, 4444); - assert_eq!(rate_limit_config.global_unwrap_per_minute, 555); - assert_eq!(rate_limit_config.global_unwrap_per_hour, 6666); - } - - #[test] - fn build_replay_protection_config_matches_server_settings() { - let state_path = PathBuf::from("/var/lib/transponder/dedup-events.log"); - let server = config::ServerConfig { - private_key: Zeroizing::new(String::new()), - private_key_file: String::new(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 77, - dedup_state_path: state_path.clone(), - dedup_retention_secs: 88, - max_notification_age_secs: 99, - max_notification_future_skew_secs: 11, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; - - let replay_config = build_replay_protection_config(&server); - - assert_eq!(replay_config.max_dedup_cache_size.get(), 77); - assert_eq!(replay_config.dedup_state_path, Some(state_path)); - assert_eq!(replay_config.dedup_retention, Duration::from_secs(88)); - assert_eq!(replay_config.max_notification_age, Duration::from_secs(99)); - assert_eq!( - replay_config.max_notification_future_skew, - Duration::from_secs(11) - ); - } - - #[test] - fn build_replay_protection_config_disables_empty_state_path() { - let server = config::ServerConfig { - private_key: Zeroizing::new(String::new()), - private_key_file: String::new(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 77, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: 88, - max_notification_age_secs: 99, - max_notification_future_skew_secs: 11, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; - - let replay_config = build_replay_protection_config(&server); - - assert_eq!(replay_config.dedup_state_path, None); - } - // ---- #146: private key file permission check (unix) ---- #[cfg(unix)] diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 4694787..66370e9 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -13,18 +13,13 @@ use tokio::sync::{RwLock, broadcast}; use tracing::{debug, error, info, warn}; use crate::config::RelayConfig; +use crate::defaults::NIP59_TIMESTAMP_TWEAK_WINDOW_SECS; use crate::error::{Error, Result}; use crate::metrics::Metrics; // Type alias to avoid confusion with our RelayStatus use nostr_sdk::RelayStatus as NostrRelayStatus; -/// Maximum NIP-59 timestamp randomization window for gift wraps. -/// -/// NIP-59 gift wraps intentionally randomize `created_at` into the past to -/// reduce timing correlation. Relay subscriptions must look back by the same -/// window or relays will filter out compliant gift wraps before delivery. -const NIP59_TIMESTAMP_TWEAK_WINDOW_SECS: u64 = nip59::RANGE_RANDOM_TIMESTAMP_TWEAK.end; const INBOX_RELAY_FETCH_TIMEOUT: Duration = Duration::from_secs(2); #[cfg(feature = "tor")] diff --git a/src/nostr/events.rs b/src/nostr/events.rs index 7548dac..d908985 100644 --- a/src/nostr/events.rs +++ b/src/nostr/events.rs @@ -20,37 +20,20 @@ use tokio::sync::{Mutex, RwLock}; use tokio::time::Instant; use tracing::{debug, trace, warn}; -use crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT; use crate::crypto::{Nip59Handler, Platform, TokenDecryptor, TokenPayload}; +use crate::defaults::{ + DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_HOUR, DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_MINUTE, DEFAULT_MAX_SIZE, + DEFAULT_MAX_TOKENS_PER_EVENT, DEFAULT_RATE_LIMIT_PER_HOUR, DEFAULT_RATE_LIMIT_PER_MINUTE, +}; use crate::error::{Error, Result}; use crate::metrics::{EventOutcome, Metrics, OperationOutcome}; use crate::push::PushDispatcher; -use crate::rate_limiter::{ - DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_HOUR, DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_MINUTE, DEFAULT_MAX_SIZE, - DEFAULT_RATE_LIMIT_PER_HOUR, DEFAULT_RATE_LIMIT_PER_MINUTE, RateLimitConfig, - RateLimitReservation, RateLimiter, -}; - -/// Maximum NIP-59 timestamp randomization window for gift wraps. -/// -/// Relays must still be queried with this full lookback because compliant gift -/// wraps randomize their outer `created_at`; durable replay state and inner -/// rumor freshness bound what we will actually dispatch from that backlog. -const NIP59_TIMESTAMP_TWEAK_WINDOW_SECS: u64 = nip59::RANGE_RANDOM_TIMESTAMP_TWEAK.end; +use crate::rate_limiter::{RateLimitConfig, RateLimitReservation, RateLimiter}; -/// Default maximum age for the unwrapped kind:446 notification request rumor. -/// -/// Outer gift-wrap timestamps are deliberately randomized by NIP-59 and are not -/// freshness signals. The inner notification rumor timestamp is the only -/// stateless bound available before dispatching a replayed backlog event. -pub const DEFAULT_MAX_NOTIFICATION_AGE_SECS: u64 = 3_600; - -/// Default tolerated clock skew for future-dated notification rumors. -pub const DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS: u64 = 300; - -/// Default duration to keep processed gift-wrap event IDs in replay state. -pub const DEFAULT_DEDUP_RETENTION_SECS: u64 = - NIP59_TIMESTAMP_TWEAK_WINDOW_SECS + DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS; +pub use crate::defaults::{ + DEFAULT_DEDUP_RETENTION_SECS, DEFAULT_MAX_DEDUP_CACHE_SIZE, DEFAULT_MAX_NOTIFICATION_AGE_SECS, + DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, +}; /// Duration to keep event IDs for deduplication. /// @@ -65,9 +48,6 @@ const DEDUP_WINDOW: Duration = Duration::from_secs(DEFAULT_DEDUP_RETENTION_SECS) /// remains the source of truth for restart/reconnect duplicate suppression. const CLEANUP_BATCH_SIZE: usize = 1000; -/// Default maximum size for the volatile deduplication cache. -pub const DEFAULT_MAX_DEDUP_CACHE_SIZE: usize = 100_000; - #[derive(Clone, Copy)] struct SeenEvent { seen_at: Instant, @@ -576,6 +556,27 @@ impl Default for TokenRateLimitConfig { } } +impl TokenRateLimitConfig { + /// Build from a validated [`crate::config::ServerConfig`]. + /// + /// Size fields are validated as non-zero at config load time. + #[must_use] + pub fn from_server_config(server: &crate::config::ServerConfig) -> Self { + Self { + max_cache_size: NonZeroUsize::new(server.max_rate_limit_cache_size) + .expect("server.max_rate_limit_cache_size validated non-zero"), + max_tokens_per_event: NonZeroUsize::new(server.max_tokens_per_event) + .expect("server.max_tokens_per_event validated non-zero"), + encrypted_token_per_minute: server.encrypted_token_rate_limit_per_minute, + encrypted_token_per_hour: server.encrypted_token_rate_limit_per_hour, + device_token_per_minute: server.device_token_rate_limit_per_minute, + device_token_per_hour: server.device_token_rate_limit_per_hour, + global_unwrap_per_minute: server.global_unwrap_rate_limit_per_minute, + global_unwrap_per_hour: server.global_unwrap_rate_limit_per_hour, + } + } +} + /// Replay-protection configuration for event IDs and notification freshness. #[derive(Debug, Clone)] pub struct ReplayProtectionConfig { @@ -622,6 +623,29 @@ impl Default for ReplayProtectionConfig { } } +impl ReplayProtectionConfig { + /// Build from a validated [`crate::config::ServerConfig`]. + #[must_use] + pub fn from_server_config(server: &crate::config::ServerConfig) -> Self { + let dedup_state_path = if server.dedup_state_path.as_os_str().is_empty() { + None + } else { + Some(server.dedup_state_path.clone()) + }; + + Self { + max_dedup_cache_size: NonZeroUsize::new(server.max_dedup_cache_size) + .expect("server.max_dedup_cache_size validated non-zero"), + dedup_state_path, + dedup_retention: Duration::from_secs(server.dedup_retention_secs), + max_notification_age: Duration::from_secs(server.max_notification_age_secs), + max_notification_future_skew: Duration::from_secs( + server.max_notification_future_skew_secs, + ), + } + } +} + impl EventProcessor { /// Create a new event processor with default settings. /// @@ -1418,6 +1442,7 @@ mod tests { use super::*; use crate::config::ApnsConfig; use crate::crypto::token::ENCRYPTED_TOKEN_SIZE; + use crate::defaults::DEFAULT_MAX_TOKENS_PER_EVENT; use crate::metrics::{EventOutcome, Metrics, OperationOutcome}; use crate::push::{ApnsClient, PushDispatcher}; use crate::test_metrics::{ @@ -1425,6 +1450,103 @@ mod tests { histogram_sample_count as histogram_count, histogram_sample_sum, }; use crate::test_vectors::scenarios; + use zeroize::Zeroizing; + + #[test] + fn token_rate_limit_config_from_server_config_matches_settings() { + let server = crate::config::ServerConfig { + private_key: Zeroizing::new(String::new()), + private_key_file: String::new(), + shutdown_timeout_secs: 10, + max_dedup_cache_size: 100_000, + dedup_state_path: PathBuf::new(), + dedup_retention_secs: DEFAULT_DEDUP_RETENTION_SECS, + max_notification_age_secs: DEFAULT_MAX_NOTIFICATION_AGE_SECS, + max_notification_future_skew_secs: DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, + max_rate_limit_cache_size: 1234, + max_tokens_per_event: 25, + encrypted_token_rate_limit_per_minute: 111, + encrypted_token_rate_limit_per_hour: 2222, + device_token_rate_limit_per_minute: 333, + device_token_rate_limit_per_hour: 4444, + max_concurrent_event_processing: 7, + global_unwrap_rate_limit_per_minute: 555, + global_unwrap_rate_limit_per_hour: 6666, + }; + + let rate_limit_config = TokenRateLimitConfig::from_server_config(&server); + + assert_eq!(rate_limit_config.max_cache_size.get(), 1234); + assert_eq!(rate_limit_config.max_tokens_per_event.get(), 25); + assert_eq!(rate_limit_config.encrypted_token_per_minute, 111); + assert_eq!(rate_limit_config.encrypted_token_per_hour, 2222); + assert_eq!(rate_limit_config.device_token_per_minute, 333); + assert_eq!(rate_limit_config.device_token_per_hour, 4444); + assert_eq!(rate_limit_config.global_unwrap_per_minute, 555); + assert_eq!(rate_limit_config.global_unwrap_per_hour, 6666); + } + + #[test] + fn replay_protection_config_from_server_config_matches_settings() { + let state_path = PathBuf::from("/var/lib/transponder/dedup-events.log"); + let server = crate::config::ServerConfig { + private_key: Zeroizing::new(String::new()), + private_key_file: String::new(), + shutdown_timeout_secs: 10, + max_dedup_cache_size: 77, + dedup_state_path: state_path.clone(), + dedup_retention_secs: 88, + max_notification_age_secs: 99, + max_notification_future_skew_secs: 11, + max_rate_limit_cache_size: 100_000, + max_tokens_per_event: DEFAULT_MAX_TOKENS_PER_EVENT, + encrypted_token_rate_limit_per_minute: 240, + encrypted_token_rate_limit_per_hour: 5000, + device_token_rate_limit_per_minute: 240, + device_token_rate_limit_per_hour: 5000, + max_concurrent_event_processing: 64, + global_unwrap_rate_limit_per_minute: 600, + global_unwrap_rate_limit_per_hour: 30_000, + }; + + let replay_config = ReplayProtectionConfig::from_server_config(&server); + + assert_eq!(replay_config.max_dedup_cache_size.get(), 77); + assert_eq!(replay_config.dedup_state_path, Some(state_path)); + assert_eq!(replay_config.dedup_retention, Duration::from_secs(88)); + assert_eq!(replay_config.max_notification_age, Duration::from_secs(99)); + assert_eq!( + replay_config.max_notification_future_skew, + Duration::from_secs(11) + ); + } + + #[test] + fn replay_protection_config_from_server_config_disables_empty_state_path() { + let server = crate::config::ServerConfig { + private_key: Zeroizing::new(String::new()), + private_key_file: String::new(), + shutdown_timeout_secs: 10, + max_dedup_cache_size: 77, + dedup_state_path: PathBuf::new(), + dedup_retention_secs: 88, + max_notification_age_secs: 99, + max_notification_future_skew_secs: 11, + max_rate_limit_cache_size: 100_000, + max_tokens_per_event: DEFAULT_MAX_TOKENS_PER_EVENT, + encrypted_token_rate_limit_per_minute: 240, + encrypted_token_rate_limit_per_hour: 5000, + device_token_rate_limit_per_minute: 240, + device_token_rate_limit_per_hour: 5000, + max_concurrent_event_processing: 64, + global_unwrap_rate_limit_per_minute: 600, + global_unwrap_rate_limit_per_hour: 30_000, + }; + + let replay_config = ReplayProtectionConfig::from_server_config(&server); + + assert_eq!(replay_config.dedup_state_path, None); + } fn gauge_value(metrics: &Metrics, name: &str) -> f64 { metric_gauge_value(metrics, name, &[]) diff --git a/src/rate_limiter.rs b/src/rate_limiter.rs index 21e0c24..02f3054 100644 --- a/src/rate_limiter.rs +++ b/src/rate_limiter.rs @@ -14,31 +14,9 @@ use lru::LruCache; use tokio::sync::RwLock; use tokio::time::Instant; -/// Default maximum cache size (100,000 entries). -pub const DEFAULT_MAX_SIZE: usize = 100_000; - -/// Default rate limit per minute (240 = 4 per second). -pub const DEFAULT_RATE_LIMIT_PER_MINUTE: u32 = 240; - -/// Default rate limit per hour. -pub const DEFAULT_RATE_LIMIT_PER_HOUR: u32 = 5000; - -/// Default global pre-unwrap admission limit per minute. -/// -/// This caps how many gift wraps the server is willing to unwrap (ECDH + seal -/// decryption) per minute across all senders. The server pubkey is public, so -/// anyone can flood it with valid kind-1059 gift wraps; this budget sheds that -/// traffic before spending asymmetric-crypto cycles. 600/minute (10/second) is -/// far above any legitimate single-server load while still bounding attacker -/// CPU cost. -pub const DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_MINUTE: u32 = 600; - -/// Default global pre-unwrap admission limit per hour. -/// -/// Companion to [`DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_MINUTE`]; bounds sustained -/// flooding over a longer window. -pub const DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_HOUR: u32 = 30_000; - +pub use crate::defaults::{ + DEFAULT_MAX_SIZE, DEFAULT_RATE_LIMIT_PER_HOUR, DEFAULT_RATE_LIMIT_PER_MINUTE, +}; /// Maximum entries to scan per cleanup cycle. const CLEANUP_BATCH_SIZE: usize = 1000; From 76c0fae33b38f1ca5cc8a16a6ba09d3d081d2f05 Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:52:46 +0100 Subject: [PATCH 02/10] refactor: split nostr/events into dedup, admission, and processor Break the 4k-line events module into focused submodules while keeping the public API unchanged. Event processor tests move with the implementation. Co-authored-by: Cursor --- src/nostr/events/admission.rs | 187 +++++++ src/nostr/events/dedup.rs | 283 +++++++++++ src/nostr/events/mod.rs | 19 + src/nostr/{events.rs => events/processor.rs} | 490 +------------------ 4 files changed, 511 insertions(+), 468 deletions(-) create mode 100644 src/nostr/events/admission.rs create mode 100644 src/nostr/events/dedup.rs create mode 100644 src/nostr/events/mod.rs rename src/nostr/{events.rs => events/processor.rs} (90%) diff --git a/src/nostr/events/admission.rs b/src/nostr/events/admission.rs new file mode 100644 index 0000000..95fe287 --- /dev/null +++ b/src/nostr/events/admission.rs @@ -0,0 +1,187 @@ +//! Per-token admission charge lifecycle and processing helpers. + +use std::time::Instant as StdInstant; + +use crate::crypto::Platform; +use crate::metrics::Metrics; +use crate::rate_limiter::{RateLimitReservation, RateLimiter}; + +#[must_use] +pub(crate) struct InFlightEventGuard<'a> { + metrics: Option<&'a Metrics>, +} + +impl<'a> InFlightEventGuard<'a> { + pub(crate) fn new(metrics: Option<&'a Metrics>) -> Self { + if let Some(m) = metrics { + m.inc_events_in_flight(); + } + + Self { metrics } + } +} + +impl Drop for InFlightEventGuard<'_> { + fn drop(&mut self) { + if let Some(m) = self.metrics { + m.dec_events_in_flight(); + } + } +} + +pub(crate) struct StageTimer(StdInstant); + +impl StageTimer { + pub(crate) fn start() -> Self { + Self(StdInstant::now()) + } + + pub(crate) fn elapsed_secs(&self) -> f64 { + self.0.elapsed().as_secs_f64() + } +} + +/// Outcome of [`EventProcessor::process_inner`]. +/// +/// Distinguishes a terminal result (notifications admitted, or the event +/// genuinely carried nothing dispatchable) from a purely transient per-token +/// rate-limit shed, which must be treated like the global-limiter shed: +/// retryable, not marked terminally seen, and not counted as processed. +pub(crate) enum ProcessOutcome { + /// The event reached a terminal state: its admitted notifications (possibly + /// zero when every token was invalid or targeted an unconfigured platform) + /// were handed to the dispatcher; the per-event count is recorded via + /// `observe_notifications_admitted_per_event`. Replays should + /// short-circuit as duplicates. + Admitted, + /// Every token the event carried was shed purely by the per-token rate + /// limiters, admitting zero notifications and dropping nothing terminally. + /// This is transient back-pressure: the event stays retryable once budget + /// recovers. + RateLimitedShed, +} + +/// A per-token admission charge that must be explicitly resolved. +/// +/// Created after the encrypted-token limiter admits a token, and (once the +/// device-token limiter also admits) after that charge too. It makes the +/// charge/refund lifecycle transactional so no admission path can silently +/// strand a spent rate-limit increment (the class of bug behind #170 and #177): +/// +/// - [`commit`](AdmissionGuard::commit) — the token was fully admitted and +/// handed toward the dispatcher; the charges are handed off to the caller's +/// dispatch-failure rollback ledger. Returns the keys and reservations. +/// - [`refund`](AdmissionGuard::refund) — the token is being dropped *after* +/// being charged (device-limiter reject per #170, or an undispatchable +/// platform / non-UTF-8 token discovered post-decrypt per #177). Every charge +/// the guard holds is rolled back so the drop leaves no spent budget. +/// - [`keep_charge`](AdmissionGuard::keep_charge) — the token is dropped but its +/// charge is *intentionally retained* (decrypt failure: an invalid encrypted +/// blob must still spend replay/spam budget, documented in #170). +/// +/// The `Drop` impl asserts the guard was resolved. Because the limiter refund is +/// `async` (the stripe lock is a tokio `RwLock`), a `Drop`-time auto-refund +/// would have to block; the guard is therefore an explicit-consume guard rather +/// than a fire-and-forget RAII one. This keeps the refund decision in exactly +/// one place per exit path while remaining `async`-correct — the trade the +/// tracker calls out as acceptable when a blocking Drop is not. +#[must_use = "an AdmissionGuard must be committed, refunded, or explicitly kept"] +pub(crate) struct AdmissionGuard<'a> { + encrypted_limiter: &'a RateLimiter<[u8; 32]>, + device_limiter: &'a RateLimiter<[u8; 32]>, + encrypted_key: [u8; 32], + encrypted_reservation: RateLimitReservation, + /// Present once the device-token limiter has also admitted the token. + device: Option<([u8; 32], RateLimitReservation)>, + resolved: bool, +} + +impl<'a> AdmissionGuard<'a> { + /// Record the encrypted-token charge just made for a token. + pub(crate) fn new( + encrypted_limiter: &'a RateLimiter<[u8; 32]>, + device_limiter: &'a RateLimiter<[u8; 32]>, + encrypted_key: [u8; 32], + encrypted_reservation: RateLimitReservation, + ) -> Self { + Self { + encrypted_limiter, + device_limiter, + encrypted_key, + encrypted_reservation, + device: None, + resolved: false, + } + } + + /// Record the device-token charge for the same token. + pub(crate) fn add_device_charge( + &mut self, + device_key: [u8; 32], + reservation: RateLimitReservation, + ) { + self.device = Some((device_key, reservation)); + } + + /// Roll back every charge this guard holds (encrypted, and device if any). + /// + /// Used on every drop-after-charge path: a device-limiter reject (#170) and + /// a post-decrypt undispatchable/non-UTF-8 drop (#177). + pub(crate) async fn refund(mut self) { + self.encrypted_limiter + .rollback_increment(&self.encrypted_key, self.encrypted_reservation) + .await; + if let Some((device_key, reservation)) = self.device { + self.device_limiter + .rollback_increment(&device_key, reservation) + .await; + } + self.resolved = true; + } + + /// Intentionally keep the charge(s) — the drop is a deliberate budget spend. + pub(crate) fn keep_charge(mut self) { + self.resolved = true; + } + + /// Hand off the fully-admitted charges to the caller's rollback ledger. + pub(crate) fn commit(mut self) -> AdmittedCharges { + self.resolved = true; + let (device_key, device_reservation) = self + .device + .expect("commit requires a device-token charge to have been recorded"); + AdmittedCharges { + encrypted_key: self.encrypted_key, + encrypted_reservation: self.encrypted_reservation, + device_key, + device_reservation, + } + } +} + +impl Drop for AdmissionGuard<'_> { + fn drop(&mut self) { + debug_assert!( + self.resolved, + "AdmissionGuard dropped without commit/refund/keep_charge — a rate-limit charge would be stranded" + ); + } +} + +/// Fully-admitted rate-limit charges for one token, recorded so a later +/// dispatch-admission failure can roll back exactly the increments it spent. +pub(crate) struct AdmittedCharges { + pub(crate) encrypted_key: [u8; 32], + pub(crate) encrypted_reservation: RateLimitReservation, + pub(crate) device_key: [u8; 32], + pub(crate) device_reservation: RateLimitReservation, +} + +/// Metrics label for a push platform (matches the dispatcher's `platform` +/// label values so drop counters aggregate with send counters). +pub(crate) fn platform_metric_label(platform: Platform) -> &'static str { + match platform { + Platform::Apns => "apns", + Platform::Fcm => "fcm", + } +} diff --git a/src/nostr/events/dedup.rs b/src/nostr/events/dedup.rs new file mode 100644 index 0000000..f28c6b8 --- /dev/null +++ b/src/nostr/events/dedup.rs @@ -0,0 +1,283 @@ +//! Event-ID deduplication and durable replay state. + +use std::collections::HashMap; +use std::fs::{self, OpenOptions as StdOpenOptions}; +use std::num::NonZeroUsize; +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use lru::LruCache; +use nostr_sdk::prelude::*; +use tokio::io::AsyncWriteExt; +use tokio::sync::Mutex; +use tokio::time::Instant; + +use crate::defaults::DEFAULT_DEDUP_RETENTION_SECS; +use crate::error::Result; + +pub(crate) const DEDUP_WINDOW: Duration = Duration::from_secs(DEFAULT_DEDUP_RETENTION_SECS); + +pub(crate) const CLEANUP_BATCH_SIZE: usize = 1000; + +#[derive(Clone, Copy)] +pub(crate) struct SeenEvent { + seen_at: Instant, + terminal: bool, +} + +impl SeenEvent { + pub(crate) fn reservation(seen_at: Instant) -> Self { + Self { + seen_at, + terminal: false, + } + } + + pub(crate) fn terminal(seen_at: Instant) -> Self { + Self { + seen_at, + terminal: true, + } + } +} + +pub(crate) enum SeenEventStore { + Bounded(LruCache), + Retained(HashMap), +} + +impl SeenEventStore { + pub(crate) fn bounded(cache_size: NonZeroUsize) -> Self { + Self::Bounded(LruCache::new(cache_size)) + } + + pub(crate) fn retained() -> Self { + Self::Retained(HashMap::new()) + } + + pub(crate) fn len(&self) -> usize { + match self { + Self::Bounded(seen) => seen.len(), + Self::Retained(seen) => seen.len(), + } + } + + pub(crate) fn contains(&self, event_id: &EventId) -> bool { + match self { + Self::Bounded(seen) => seen.contains(event_id), + Self::Retained(seen) => seen.contains_key(event_id), + } + } + + pub(crate) fn put(&mut self, event_id: EventId, seen_event: SeenEvent) { + match self { + Self::Bounded(seen) => { + seen.put(event_id, seen_event); + } + Self::Retained(seen) => { + seen.insert(event_id, seen_event); + } + } + } + + pub(crate) fn pop(&mut self, event_id: &EventId) { + match self { + Self::Bounded(seen) => { + seen.pop(event_id); + } + Self::Retained(seen) => { + seen.remove(event_id); + } + } + } + + /// Refresh an existing reservation into a terminal entry in place. + /// + /// On the success hot path the ID is already present from `try_reserve`, so + /// this mutates its `SeenEvent` (new `seen_at`, `terminal = true`) instead of + /// re-inserting a fresh value. Returns `true` when the set size changed + /// (i.e. the entry was absent and had to be inserted — e.g. an entry evicted + /// by LRU pressure between reservation and completion), so the caller only + /// pays a `dedup_cache_size` gauge write when the length actually moved. This + /// removes the redundant second gauge update the double-lock success path + /// carried (#197) while preserving the completion-timestamp-refresh and + /// terminal-flip semantics `mark_seen` provided. + pub(crate) fn mark_terminal(&mut self, event_id: EventId, seen_at: Instant) -> bool { + match self { + Self::Bounded(seen) => { + if let Some(existing) = seen.peek_mut(&event_id) { + *existing = SeenEvent::terminal(seen_at); + // Promote to most-recently-used to match the prior `put` + // behavior, which refreshed LRU position on completion. + seen.promote(&event_id); + false + } else { + seen.put(event_id, SeenEvent::terminal(seen_at)); + true + } + } + Self::Retained(seen) => seen + .insert(event_id, SeenEvent::terminal(seen_at)) + .is_none(), + } + } + + pub(crate) fn expired_keys(&self, now: Instant, retention: Duration) -> Vec { + match self { + Self::Bounded(seen) => seen + .iter() + .rev() + .take(CLEANUP_BATCH_SIZE) + .filter(|(_, seen_event)| now.duration_since(seen_event.seen_at) >= retention) + .map(|(id, _)| *id) + .collect(), + Self::Retained(seen) => seen + .iter() + .filter(|(_, seen_event)| now.duration_since(seen_event.seen_at) >= retention) + .map(|(id, _)| *id) + .collect(), + } + } + + pub(crate) fn terminal_entries( + &self, + now_wall: u64, + now_instant: Instant, + ) -> Vec<(EventId, u64)> { + match self { + Self::Bounded(seen) => seen + .iter() + .filter_map(|(event_id, seen_event)| { + if seen_event.terminal { + Some(( + *event_id, + instant_to_unix_secs(seen_event.seen_at, now_wall, now_instant), + )) + } else { + None + } + }) + .collect(), + Self::Retained(seen) => seen + .iter() + .filter_map(|(event_id, seen_event)| { + if seen_event.terminal { + Some(( + *event_id, + instant_to_unix_secs(seen_event.seen_at, now_wall, now_instant), + )) + } else { + None + } + }) + .collect(), + } + } +} + +pub(crate) struct PersistentDedupState { + pub(crate) path: PathBuf, + pub(crate) write_lock: Mutex<()>, +} + +impl PersistentDedupState { + pub(crate) fn new(path: PathBuf) -> Result { + Self::prepare_path(&path)?; + Ok(Self { + path, + write_lock: Mutex::new(()), + }) + } + + pub(crate) fn prepare_path(path: &Path) -> Result<()> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent)?; + } + + let mut options = StdOpenOptions::new(); + options.create(true).append(true).read(true); + #[cfg(unix)] + options.mode(0o600); + let _file = options.open(path)?; + + #[cfg(unix)] + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + Ok(()) + } + + pub(crate) fn load_seen_events(path: &Path, retention: Duration) -> Result { + Self::prepare_path(path)?; + + let now_wall = Timestamp::now().as_secs(); + let now_instant = Instant::now(); + let mut seen = SeenEventStore::retained(); + let contents = fs::read_to_string(path)?; + + for line in contents.lines() { + let mut fields = line.split_whitespace(); + let (Some(event_id_hex), Some(seen_at_secs), None) = + (fields.next(), fields.next(), fields.next()) + else { + continue; + }; + let Ok(event_id) = EventId::from_hex(event_id_hex) else { + continue; + }; + let Ok(seen_at_secs) = seen_at_secs.parse::() else { + continue; + }; + let age = now_wall.saturating_sub(seen_at_secs); + if age > retention.as_secs() { + continue; + } + let seen_at = now_instant + .checked_sub(Duration::from_secs(age)) + .unwrap_or(now_instant); + seen.put(event_id, SeenEvent::terminal(seen_at)); + } + + Ok(seen) + } + + pub(crate) async fn append_seen_locked( + &self, + event_id: EventId, + seen_at_secs: u64, + ) -> std::io::Result<()> { + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + .await?; + #[cfg(unix)] + tokio::fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600)).await?; + file.write_all(format!("{} {}\n", event_id.to_hex(), seen_at_secs).as_bytes()) + .await?; + file.flush().await + } + + pub(crate) async fn rewrite_locked(&self, entries: &[(EventId, u64)]) -> std::io::Result<()> { + let tmp_path = self.path.with_extension("tmp"); + let mut contents = String::new(); + for (event_id, seen_at_secs) in entries { + use std::fmt::Write as _; + let _ = writeln!(&mut contents, "{} {}", event_id.to_hex(), seen_at_secs); + } + tokio::fs::write(&tmp_path, contents).await?; + #[cfg(unix)] + tokio::fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o600)).await?; + tokio::fs::rename(&tmp_path, &self.path).await + } +} + +pub(crate) fn instant_to_unix_secs(seen_at: Instant, now_wall: u64, now_instant: Instant) -> u64 { + let age = now_instant + .checked_duration_since(seen_at) + .unwrap_or_default() + .as_secs(); + now_wall.saturating_sub(age) +} diff --git a/src/nostr/events/mod.rs b/src/nostr/events/mod.rs new file mode 100644 index 0000000..137d9fe --- /dev/null +++ b/src/nostr/events/mod.rs @@ -0,0 +1,19 @@ +//! Event processing for incoming Nostr events. +//! +//! Handles deduplication and processing of gift-wrapped notification requests, +//! including rate limiting to prevent spam and replay attacks. + +mod admission; +mod dedup; +mod processor; + +pub use processor::{EventProcessor, ReplayProtectionConfig, TokenRateLimitConfig}; + +#[allow(unused_imports)] +pub use crate::defaults::{ + DEFAULT_DEDUP_RETENTION_SECS, DEFAULT_MAX_DEDUP_CACHE_SIZE, DEFAULT_MAX_NOTIFICATION_AGE_SECS, + DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, +}; + +#[allow(unused_imports)] +pub(crate) use dedup::DEDUP_WINDOW; diff --git a/src/nostr/events.rs b/src/nostr/events/processor.rs similarity index 90% rename from src/nostr/events.rs rename to src/nostr/events/processor.rs index d908985..4fbb627 100644 --- a/src/nostr/events.rs +++ b/src/nostr/events/processor.rs @@ -1,485 +1,34 @@ -//! Event processing for incoming Nostr events. -//! -//! Handles deduplication and processing of gift-wrapped notification requests, -//! including rate limiting to prevent spam and replay attacks. +//! Gift-wrapped notification event processing. -use std::collections::HashMap; -use std::fs::{self, OpenOptions as StdOpenOptions}; use std::num::NonZeroUsize; -#[cfg(unix)] -use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::Arc; -use std::time::{Duration, Instant as StdInstant}; +use std::time::Duration; -use lru::LruCache; use nostr_sdk::prelude::*; use sha2::{Digest, Sha256}; -use tokio::io::AsyncWriteExt; -use tokio::sync::{Mutex, RwLock}; +use tokio::sync::RwLock; use tokio::time::Instant; use tracing::{debug, trace, warn}; -use crate::crypto::{Nip59Handler, Platform, TokenDecryptor, TokenPayload}; +use super::admission::{ + AdmissionGuard, AdmittedCharges, InFlightEventGuard, ProcessOutcome, StageTimer, + platform_metric_label, +}; +use super::dedup::{DEDUP_WINDOW, PersistentDedupState, SeenEvent, SeenEventStore}; +use crate::crypto::{Nip59Handler, TokenDecryptor, TokenPayload}; use crate::defaults::{ DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_HOUR, DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_MINUTE, DEFAULT_MAX_SIZE, DEFAULT_MAX_TOKENS_PER_EVENT, DEFAULT_RATE_LIMIT_PER_HOUR, DEFAULT_RATE_LIMIT_PER_MINUTE, }; +use crate::defaults::{ + DEFAULT_MAX_DEDUP_CACHE_SIZE, DEFAULT_MAX_NOTIFICATION_AGE_SECS, + DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, +}; use crate::error::{Error, Result}; use crate::metrics::{EventOutcome, Metrics, OperationOutcome}; use crate::push::PushDispatcher; -use crate::rate_limiter::{RateLimitConfig, RateLimitReservation, RateLimiter}; - -pub use crate::defaults::{ - DEFAULT_DEDUP_RETENTION_SECS, DEFAULT_MAX_DEDUP_CACHE_SIZE, DEFAULT_MAX_NOTIFICATION_AGE_SECS, - DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, -}; - -/// Duration to keep event IDs for deduplication. -/// -/// The cache must cover the relay subscription lookback; otherwise a restart or -/// reconnect can re-deliver backlog entries after the old 5-minute in-memory -/// window expires. -const DEDUP_WINDOW: Duration = Duration::from_secs(DEFAULT_DEDUP_RETENTION_SECS); - -/// Maximum number of volatile LRU entries to scan per cleanup cycle. -/// -/// Durable replay state scans the retained set so retention, not LRU capacity, -/// remains the source of truth for restart/reconnect duplicate suppression. -const CLEANUP_BATCH_SIZE: usize = 1000; - -#[derive(Clone, Copy)] -struct SeenEvent { - seen_at: Instant, - terminal: bool, -} - -impl SeenEvent { - fn reservation(seen_at: Instant) -> Self { - Self { - seen_at, - terminal: false, - } - } - - fn terminal(seen_at: Instant) -> Self { - Self { - seen_at, - terminal: true, - } - } -} - -enum SeenEventStore { - Bounded(LruCache), - Retained(HashMap), -} - -impl SeenEventStore { - fn bounded(cache_size: NonZeroUsize) -> Self { - Self::Bounded(LruCache::new(cache_size)) - } - - fn retained() -> Self { - Self::Retained(HashMap::new()) - } - - fn len(&self) -> usize { - match self { - Self::Bounded(seen) => seen.len(), - Self::Retained(seen) => seen.len(), - } - } - - fn contains(&self, event_id: &EventId) -> bool { - match self { - Self::Bounded(seen) => seen.contains(event_id), - Self::Retained(seen) => seen.contains_key(event_id), - } - } - - fn put(&mut self, event_id: EventId, seen_event: SeenEvent) { - match self { - Self::Bounded(seen) => { - seen.put(event_id, seen_event); - } - Self::Retained(seen) => { - seen.insert(event_id, seen_event); - } - } - } - - fn pop(&mut self, event_id: &EventId) { - match self { - Self::Bounded(seen) => { - seen.pop(event_id); - } - Self::Retained(seen) => { - seen.remove(event_id); - } - } - } - - /// Refresh an existing reservation into a terminal entry in place. - /// - /// On the success hot path the ID is already present from `try_reserve`, so - /// this mutates its `SeenEvent` (new `seen_at`, `terminal = true`) instead of - /// re-inserting a fresh value. Returns `true` when the set size changed - /// (i.e. the entry was absent and had to be inserted — e.g. an entry evicted - /// by LRU pressure between reservation and completion), so the caller only - /// pays a `dedup_cache_size` gauge write when the length actually moved. This - /// removes the redundant second gauge update the double-lock success path - /// carried (#197) while preserving the completion-timestamp-refresh and - /// terminal-flip semantics `mark_seen` provided. - fn mark_terminal(&mut self, event_id: EventId, seen_at: Instant) -> bool { - match self { - Self::Bounded(seen) => { - if let Some(existing) = seen.peek_mut(&event_id) { - *existing = SeenEvent::terminal(seen_at); - // Promote to most-recently-used to match the prior `put` - // behavior, which refreshed LRU position on completion. - seen.promote(&event_id); - false - } else { - seen.put(event_id, SeenEvent::terminal(seen_at)); - true - } - } - Self::Retained(seen) => seen - .insert(event_id, SeenEvent::terminal(seen_at)) - .is_none(), - } - } - - fn expired_keys(&self, now: Instant, retention: Duration) -> Vec { - match self { - Self::Bounded(seen) => seen - .iter() - .rev() - .take(CLEANUP_BATCH_SIZE) - .filter(|(_, seen_event)| now.duration_since(seen_event.seen_at) >= retention) - .map(|(id, _)| *id) - .collect(), - Self::Retained(seen) => seen - .iter() - .filter(|(_, seen_event)| now.duration_since(seen_event.seen_at) >= retention) - .map(|(id, _)| *id) - .collect(), - } - } - - fn terminal_entries(&self, now_wall: u64, now_instant: Instant) -> Vec<(EventId, u64)> { - match self { - Self::Bounded(seen) => seen - .iter() - .filter_map(|(event_id, seen_event)| { - if seen_event.terminal { - Some(( - *event_id, - instant_to_unix_secs(seen_event.seen_at, now_wall, now_instant), - )) - } else { - None - } - }) - .collect(), - Self::Retained(seen) => seen - .iter() - .filter_map(|(event_id, seen_event)| { - if seen_event.terminal { - Some(( - *event_id, - instant_to_unix_secs(seen_event.seen_at, now_wall, now_instant), - )) - } else { - None - } - }) - .collect(), - } - } -} - -struct PersistentDedupState { - path: PathBuf, - write_lock: Mutex<()>, -} - -impl PersistentDedupState { - fn new(path: PathBuf) -> Result { - Self::prepare_path(&path)?; - Ok(Self { - path, - write_lock: Mutex::new(()), - }) - } - - fn prepare_path(path: &Path) -> Result<()> { - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - fs::create_dir_all(parent)?; - } - - let mut options = StdOpenOptions::new(); - options.create(true).append(true).read(true); - #[cfg(unix)] - options.mode(0o600); - let _file = options.open(path)?; - - #[cfg(unix)] - fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; - Ok(()) - } - - fn load_seen_events(path: &Path, retention: Duration) -> Result { - Self::prepare_path(path)?; - - let now_wall = Timestamp::now().as_secs(); - let now_instant = Instant::now(); - let mut seen = SeenEventStore::retained(); - let contents = fs::read_to_string(path)?; - - for line in contents.lines() { - let mut fields = line.split_whitespace(); - let (Some(event_id_hex), Some(seen_at_secs), None) = - (fields.next(), fields.next(), fields.next()) - else { - continue; - }; - let Ok(event_id) = EventId::from_hex(event_id_hex) else { - continue; - }; - let Ok(seen_at_secs) = seen_at_secs.parse::() else { - continue; - }; - let age = now_wall.saturating_sub(seen_at_secs); - if age > retention.as_secs() { - continue; - } - let seen_at = now_instant - .checked_sub(Duration::from_secs(age)) - .unwrap_or(now_instant); - seen.put(event_id, SeenEvent::terminal(seen_at)); - } - - Ok(seen) - } - - async fn append_seen_locked( - &self, - event_id: EventId, - seen_at_secs: u64, - ) -> std::io::Result<()> { - let mut file = tokio::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&self.path) - .await?; - #[cfg(unix)] - tokio::fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600)).await?; - file.write_all(format!("{} {}\n", event_id.to_hex(), seen_at_secs).as_bytes()) - .await?; - file.flush().await - } - - async fn rewrite_locked(&self, entries: &[(EventId, u64)]) -> std::io::Result<()> { - let tmp_path = self.path.with_extension("tmp"); - let mut contents = String::new(); - for (event_id, seen_at_secs) in entries { - use std::fmt::Write as _; - let _ = writeln!(&mut contents, "{} {}", event_id.to_hex(), seen_at_secs); - } - tokio::fs::write(&tmp_path, contents).await?; - #[cfg(unix)] - tokio::fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o600)).await?; - tokio::fs::rename(&tmp_path, &self.path).await - } -} - -fn instant_to_unix_secs(seen_at: Instant, now_wall: u64, now_instant: Instant) -> u64 { - let age = now_instant - .checked_duration_since(seen_at) - .unwrap_or_default() - .as_secs(); - now_wall.saturating_sub(age) -} - -#[must_use] -struct InFlightEventGuard<'a> { - metrics: Option<&'a Metrics>, -} - -impl<'a> InFlightEventGuard<'a> { - fn new(metrics: Option<&'a Metrics>) -> Self { - if let Some(m) = metrics { - m.inc_events_in_flight(); - } - - Self { metrics } - } -} - -impl Drop for InFlightEventGuard<'_> { - fn drop(&mut self) { - if let Some(m) = self.metrics { - m.dec_events_in_flight(); - } - } -} - -struct StageTimer(StdInstant); - -impl StageTimer { - fn start() -> Self { - Self(StdInstant::now()) - } - - fn elapsed_secs(&self) -> f64 { - self.0.elapsed().as_secs_f64() - } -} - -/// Outcome of [`EventProcessor::process_inner`]. -/// -/// Distinguishes a terminal result (notifications admitted, or the event -/// genuinely carried nothing dispatchable) from a purely transient per-token -/// rate-limit shed, which must be treated like the global-limiter shed: -/// retryable, not marked terminally seen, and not counted as processed. -enum ProcessOutcome { - /// The event reached a terminal state: its admitted notifications (possibly - /// zero when every token was invalid or targeted an unconfigured platform) - /// were handed to the dispatcher; the per-event count is recorded via - /// `observe_notifications_admitted_per_event`. Replays should - /// short-circuit as duplicates. - Admitted, - /// Every token the event carried was shed purely by the per-token rate - /// limiters, admitting zero notifications and dropping nothing terminally. - /// This is transient back-pressure: the event stays retryable once budget - /// recovers. - RateLimitedShed, -} - -/// A per-token admission charge that must be explicitly resolved. -/// -/// Created after the encrypted-token limiter admits a token, and (once the -/// device-token limiter also admits) after that charge too. It makes the -/// charge/refund lifecycle transactional so no admission path can silently -/// strand a spent rate-limit increment (the class of bug behind #170 and #177): -/// -/// - [`commit`](AdmissionGuard::commit) — the token was fully admitted and -/// handed toward the dispatcher; the charges are handed off to the caller's -/// dispatch-failure rollback ledger. Returns the keys and reservations. -/// - [`refund`](AdmissionGuard::refund) — the token is being dropped *after* -/// being charged (device-limiter reject per #170, or an undispatchable -/// platform / non-UTF-8 token discovered post-decrypt per #177). Every charge -/// the guard holds is rolled back so the drop leaves no spent budget. -/// - [`keep_charge`](AdmissionGuard::keep_charge) — the token is dropped but its -/// charge is *intentionally retained* (decrypt failure: an invalid encrypted -/// blob must still spend replay/spam budget, documented in #170). -/// -/// The `Drop` impl asserts the guard was resolved. Because the limiter refund is -/// `async` (the stripe lock is a tokio `RwLock`), a `Drop`-time auto-refund -/// would have to block; the guard is therefore an explicit-consume guard rather -/// than a fire-and-forget RAII one. This keeps the refund decision in exactly -/// one place per exit path while remaining `async`-correct — the trade the -/// tracker calls out as acceptable when a blocking Drop is not. -#[must_use = "an AdmissionGuard must be committed, refunded, or explicitly kept"] -struct AdmissionGuard<'a> { - encrypted_limiter: &'a RateLimiter<[u8; 32]>, - device_limiter: &'a RateLimiter<[u8; 32]>, - encrypted_key: [u8; 32], - encrypted_reservation: RateLimitReservation, - /// Present once the device-token limiter has also admitted the token. - device: Option<([u8; 32], RateLimitReservation)>, - resolved: bool, -} - -impl<'a> AdmissionGuard<'a> { - /// Record the encrypted-token charge just made for a token. - fn new( - encrypted_limiter: &'a RateLimiter<[u8; 32]>, - device_limiter: &'a RateLimiter<[u8; 32]>, - encrypted_key: [u8; 32], - encrypted_reservation: RateLimitReservation, - ) -> Self { - Self { - encrypted_limiter, - device_limiter, - encrypted_key, - encrypted_reservation, - device: None, - resolved: false, - } - } - - /// Record the device-token charge for the same token. - fn add_device_charge(&mut self, device_key: [u8; 32], reservation: RateLimitReservation) { - self.device = Some((device_key, reservation)); - } - - /// Roll back every charge this guard holds (encrypted, and device if any). - /// - /// Used on every drop-after-charge path: a device-limiter reject (#170) and - /// a post-decrypt undispatchable/non-UTF-8 drop (#177). - async fn refund(mut self) { - self.encrypted_limiter - .rollback_increment(&self.encrypted_key, self.encrypted_reservation) - .await; - if let Some((device_key, reservation)) = self.device { - self.device_limiter - .rollback_increment(&device_key, reservation) - .await; - } - self.resolved = true; - } - - /// Intentionally keep the charge(s) — the drop is a deliberate budget spend. - fn keep_charge(mut self) { - self.resolved = true; - } - - /// Hand off the fully-admitted charges to the caller's rollback ledger. - fn commit(mut self) -> AdmittedCharges { - self.resolved = true; - let (device_key, device_reservation) = self - .device - .expect("commit requires a device-token charge to have been recorded"); - AdmittedCharges { - encrypted_key: self.encrypted_key, - encrypted_reservation: self.encrypted_reservation, - device_key, - device_reservation, - } - } -} - -impl Drop for AdmissionGuard<'_> { - fn drop(&mut self) { - debug_assert!( - self.resolved, - "AdmissionGuard dropped without commit/refund/keep_charge — a rate-limit charge would be stranded" - ); - } -} - -/// Fully-admitted rate-limit charges for one token, recorded so a later -/// dispatch-admission failure can roll back exactly the increments it spent. -struct AdmittedCharges { - encrypted_key: [u8; 32], - encrypted_reservation: RateLimitReservation, - device_key: [u8; 32], - device_reservation: RateLimitReservation, -} - -/// Metrics label for a push platform (matches the dispatcher's `platform` -/// label values so drop counters aggregate with send counters). -fn platform_metric_label(platform: Platform) -> &'static str { - match platform { - Platform::Apns => "apns", - Platform::Fcm => "fcm", - } -} +use crate::rate_limiter::{RateLimitConfig, RateLimiter}; /// Event processor for handling incoming gift-wrapped notifications. pub struct EventProcessor { @@ -1436,14 +985,19 @@ impl EventProcessor { .unwrap_or(0) } } - #[cfg(test)] mod tests { use super::*; use crate::config::ApnsConfig; + use crate::crypto::Platform; use crate::crypto::token::ENCRYPTED_TOKEN_SIZE; - use crate::defaults::DEFAULT_MAX_TOKENS_PER_EVENT; + use crate::defaults::{ + DEFAULT_DEDUP_RETENTION_SECS, DEFAULT_MAX_DEDUP_CACHE_SIZE, + DEFAULT_MAX_NOTIFICATION_AGE_SECS, DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, + DEFAULT_MAX_TOKENS_PER_EVENT, + }; use crate::metrics::{EventOutcome, Metrics, OperationOutcome}; + use crate::nostr::events::dedup::{CLEANUP_BATCH_SIZE, DEDUP_WINDOW, instant_to_unix_secs}; use crate::push::{ApnsClient, PushDispatcher}; use crate::test_metrics::{ counter_value, gauge_value as metric_gauge_value, From f49f8bf0722bb353f76d02cce2139a86a2b43ab4 Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:55:20 +0100 Subject: [PATCH 03/10] refactor: extract lib.rs and move server wiring into app module Introduce a library crate with app::run as the composition root so main.rs only handles CLI parsing, telemetry init, and logging setup. Co-authored-by: Cursor --- src/app.rs | 1867 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 25 + src/main.rs | 1892 +-------------------------------------------------- 3 files changed, 1899 insertions(+), 1885 deletions(-) create mode 100644 src/app.rs create mode 100644 src/lib.rs diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..3f75d29 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,1867 @@ +//! Server startup, event loop, and operational wiring. + +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use nostr_sdk::prelude::*; +use tokio::sync::broadcast::{self, error::RecvError}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore, watch}; +use tokio_util::task::TaskTracker; +use tracing::{debug, error, info, warn}; +use tracing_subscriber::{EnvFilter, Layer, fmt, prelude::*}; +use zeroize::Zeroizing; + +use crate::config::AppConfig; +use crate::crypto::{Nip59Handler, TokenDecryptor}; +use crate::metrics::Metrics; +use crate::nostr::client::RelayClient; +use crate::nostr::events::EventProcessor; +use crate::push::{ApnsClient, FcmClient, PushDispatcher}; +use crate::server::HealthServer; +use crate::shutdown::{ShutdownHandler, ShutdownTrigger}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NotificationReceiveAction { + Continue, + Shutdown, +} + +fn classify_notification_receive_error(error: &RecvError) -> NotificationReceiveAction { + match error { + RecvError::Lagged(_) => NotificationReceiveAction::Continue, + RecvError::Closed => NotificationReceiveAction::Shutdown, + } +} + +fn record_notification_receive_metrics(metrics: Option<&Metrics>, error: &RecvError) { + if let RecvError::Lagged(skipped) = error + && let Some(metrics) = metrics + { + metrics.record_relay_notifications_lagged(); + metrics.record_relay_notifications_dropped(*skipped); + } +} + +#[cfg(feature = "tor")] +const TOR_FEATURE_ENABLED: bool = true; +#[cfg(not(feature = "tor"))] +const TOR_FEATURE_ENABLED: bool = false; + +/// Cadence of the background relay-status refresh. +/// +/// The refresher recomputes the cached relay connection status (and the +/// `relays_connected` gauges) on this fixed interval, so readiness probes can +/// be pure reads of the snapshot and gauge updates track connection state +/// rather than probe traffic. Five seconds keeps `/ready` at most one refresh +/// interval behind real connection changes for typical orchestrator probe +/// periods (10s+) while enumerating the relay pool rarely. +const RELAY_STATUS_REFRESH_INTERVAL: Duration = Duration::from_secs(5); + +fn validate_startup_config( + server_private_key: &str, + relays: &crate::config::RelayConfig, +) -> Result<()> { + if server_private_key.is_empty() { + anyhow::bail!("Server private key is required"); + } + + if relays.clearnet.is_empty() && relays.onion.is_empty() { + anyhow::bail!("At least one relay must be configured"); + } + + if !TOR_FEATURE_ENABLED && !relays.onion.is_empty() { + anyhow::bail!("Onion relays require the 'tor' feature"); + } + + Ok(()) +} + +fn parse_server_secret_key(server_private_key: &str) -> Result { + SecretKey::parse(server_private_key).context("Invalid server private key") +} + +/// Number of permits to use for the event-processing semaphore. +/// +/// Bounds total in-flight gift-wrap unwrap (ECDH) work. A configured value of +/// zero would deadlock the event loop (no permit could ever be acquired), so it +/// is clamped up to a single permit, preserving sequential processing. +#[must_use] +fn event_processing_permits(max_concurrent_event_processing: usize) -> usize { + max_concurrent_event_processing.max(1) +} + +/// Outcome of racing relay startup against a shutdown signal. +#[derive(Debug)] +enum StartupOutcome { + /// Startup finished; carries the connect result. + Connected(T), + /// A shutdown signal arrived before startup finished; carries the signal + /// future's output (e.g. the [`crate::shutdown::ShutdownReason`]). + ShutdownRequested(S), +} + +/// Awaits a startup future while remaining responsive to a shutdown signal. +/// +/// The signal future must already have its SIGTERM/SIGINT handlers installed +/// *before* the startup work is awaited, so a signal that arrives while waiting +/// for relay connections exits promptly instead of being ignored until the +/// connect timeout elapses. Shutdown is preferred (`biased`) so a signal that is +/// already pending wins over a startup future that resolves in the same poll. +async fn run_startup_or_shutdown( + startup: StartupFut, + signal_fut: SignalFut, +) -> StartupOutcome +where + StartupFut: std::future::Future, + SignalFut: std::future::Future, +{ + tokio::select! { + biased; + + reason = signal_fut => StartupOutcome::ShutdownRequested(reason), + result = startup => StartupOutcome::Connected(result), + } +} + +/// Map how shutdown was initiated to the process exit result. +/// +/// A signal-initiated stop is a clean exit. An internally triggered stop means +/// a supervised critical task failed; exiting non-zero makes orchestrators +/// with on-failure restart policies reschedule the process instead of +/// treating the stop as intentional. +fn shutdown_result(reason: crate::shutdown::ShutdownReason) -> Result<()> { + match reason { + crate::shutdown::ShutdownReason::Signal => Ok(()), + crate::shutdown::ShutdownReason::InternalTrigger => Err(anyhow::anyhow!( + "shutting down after a critical task failure" + )), + } +} + +async fn acquire_event_processing_permit_or_shutdown( + semaphore: Arc, + shutdown: &mut watch::Receiver, +) -> Option { + // Prefer shutdown over newly available capacity so teardown does not admit + // more event-processing work after a shutdown signal is visible. + tokio::select! { + biased; + + _ = shutdown.changed() => { + debug!("Event processor shutting down before permit acquisition"); + None + }, + permit = semaphore.acquire_owned() => permit.ok(), + } +} + +/// Why the event-consumer loop exited. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EventLoopExit { + /// The loop observed the shutdown signal; expected during teardown. + ShutdownRequested, + /// The relay notification channel closed while the process was supposed + /// to keep running; unexpected, must tear the process down. + ChannelClosed, +} + +/// Map an event-loop exit to a supervision result. +/// +/// A shutdown-signal exit is the expected teardown path; a channel close means +/// event processing silently died and, left alone, the process would become a +/// zombie that reports healthy while handling zero events (#151). +fn event_loop_exit_to_supervision_result( + exit: EventLoopExit, +) -> std::result::Result<(), &'static str> { + match exit { + EventLoopExit::ShutdownRequested => Ok(()), + EventLoopExit::ChannelClosed => Err("relay notification channel closed"), + } +} + +/// Trigger a process-wide shutdown when a critical task fails. +/// +/// Expected exits pass `Ok(())` and are left alone. Any `Err` means a task the +/// process cannot live without (health server, event loop) died while the rest +/// of the process kept running; triggering shutdown makes the process exit so +/// the orchestrator restarts it instead of leaving a zombie. +fn supervise_critical_task( + task: &'static str, + result: std::result::Result<(), E>, + shutdown_trigger: &ShutdownTrigger, +) { + if let Err(error) = result { + error!(task, error = %error, "Critical task exited unexpectedly; triggering shutdown"); + shutdown_trigger.trigger(); + } +} + +/// Drain relay notifications and process events with bounded concurrency. +/// +/// Each admitted event is processed in its own task spawned onto `event_tasks`, +/// gated by a semaphore. The receive loop drains the broadcast channel quickly +/// so it does not fall behind and trigger `Lagged` overflow, while the +/// semaphore caps total in-flight gift-wrap unwrap (ECDH) work so a flood +/// cannot spawn unbounded crypto tasks. An owned permit is acquired BEFORE +/// spawning and dropped when the spawned task finishes; when the budget is +/// exhausted the loop awaits a free permit (applying back-pressure) while +/// still remaining responsive to shutdown signals. +/// +/// Spawning through the [`TaskTracker`] keeps in-flight unwrap work joinable: +/// [`staged_teardown`] waits for these tasks before draining the push +/// dispatcher, so notifications mid-unwrap at shutdown are delivered instead +/// of aborted (#84, #173). +async fn run_event_loop( + mut notifications: broadcast::Receiver, + mut shutdown: watch::Receiver, + event_semaphore: Arc, + processor: Arc, + event_tasks: TaskTracker, + metrics: Option, +) -> EventLoopExit { + loop { + tokio::select! { + _ = shutdown.changed() => { + info!("Event processor shutting down"); + return EventLoopExit::ShutdownRequested; + } + result = notifications.recv() => { + match result { + Ok(notification) => { + let RelayPoolNotification::Event { event, .. } = notification else { + continue; + }; + + // Acquire a permit before spawning so total in-flight + // unwrap work stays bounded. `None` means shutdown won + // while waiting (or the semaphore closed, which never + // happens here), so intentionally drop this event. + let Some(permit) = acquire_event_processing_permit_or_shutdown( + Arc::clone(&event_semaphore), + &mut shutdown, + ) + .await + else { + return EventLoopExit::ShutdownRequested; + }; + + let processor = processor.clone(); + event_tasks.spawn(async move { + // Hold the permit for the lifetime of the task; it + // is released when `permit` is dropped on return. + let _permit = permit; + if let Err(e) = processor.process(&event).await { + debug!(error = %e, "Event processing error"); + } + }); + } + Err(e) => { + record_notification_receive_metrics(metrics.as_ref(), &e); + + match classify_notification_receive_error(&e) { + NotificationReceiveAction::Continue => { + warn!(error = %e, "Lagged relay notifications, continuing"); + } + NotificationReceiveAction::Shutdown => { + error!(error = %e, "Notification channel closed"); + return EventLoopExit::ChannelClosed; + } + } + } + } + } + } + } +} + +/// Tear down the pipeline in dependency order, producers before consumers. +/// +/// The stage order is load-bearing (#173, #84): +/// +/// 1. Join the event loop. The shutdown watch has already fired, so it stops +/// admitting events; joining it guarantees no new processing task can be +/// spawned after the tracker is closed. +/// 2. Close and drain the task tracker. In-flight gift-wrap unwraps run to +/// completion and their `dispatch()` calls are still accepted, because the +/// push dispatcher has not flipped `shutting_down` yet. +/// 3. Drain the push dispatcher. `wait_for_completion` rejects new dispatches +/// from its first instant, so it must run only after every producer above +/// is quiesced. +/// 4. Disconnect relays. This closes the notification broadcast channel, which +/// is only safe (no spurious `Closed` error) once the event loop is gone. +/// 5. Join the remaining supervised tasks (health server, cleanup, relay +/// status refresher). +/// +/// The caller bounds the whole sequence with the configured shutdown timeout +/// via [`crate::shutdown::graceful_shutdown`]. +async fn staged_teardown( + event_handle: tokio::task::JoinHandle<()>, + event_tasks: TaskTracker, + push_dispatcher: Arc, + relay_client: Arc, + health_handle: tokio::task::JoinHandle<()>, + cleanup_handle: tokio::task::JoinHandle<()>, + status_refresh_handle: tokio::task::JoinHandle<()>, +) { + let _ = event_handle.await; + + event_tasks.close(); + event_tasks.wait().await; + + push_dispatcher.wait_for_completion().await; + + if let Err(e) = relay_client.disconnect().await { + warn!(error = %e, "Error disconnecting from relays"); + } + + let _ = tokio::join!(health_handle, cleanup_handle, status_refresh_handle); +} + +/// Bring the server up and run until shutdown. +/// +/// Split from `main` so a failure during startup or the run loop is logged at +/// `ERROR` — and therefore reported to GlitchTip — instead of only surfacing on +/// process exit. The GlitchTip guard stays in `main` so it outlives this call +/// and flushes the captured event. Failures *before* this point (config load, +/// `crate::telemetry::init`, `init_logging`) occur before the subscriber and client +/// exist, so they surface only on stderr, not in GlitchTip. +pub async fn run(mut config: AppConfig) -> Result<()> { + // Note: the `metrics.enabled && !health.enabled` case is no longer a + // silent-loss footgun. The health server now binds its listener and serves + // `/metrics` whenever a metrics collector exists — independent of + // `health.enabled` — and emits its own targeted warning at bind time (see + // `server::health::HealthServer::bind`). The #196 rider's structural fix + // landed there, so no separate load-time warning is needed here. + + // Initialize metrics + let metrics = if config.metrics.enabled { + match Metrics::new() { + Ok(m) => { + m.init_server_info(env!("CARGO_PKG_VERSION")); + info!("Metrics initialized"); + Some(m) + } + Err(e) => { + error!(error = %e, "Failed to initialize metrics"); + None + } + } + } else { + info!("Metrics disabled"); + None + }; + + let server_private_key = resolve_server_private_key(&mut config.server)?; + + // Validate configuration + validate_startup_config(server_private_key.as_str(), &config.relays)?; + + // Create server keys + let secret_key = parse_server_secret_key(server_private_key.as_str())?; + let keys = Keys::new(secret_key); + drop(server_private_key); + + debug!( + pubkey = %keys.public_key().to_hex(), + "Server public key" + ); + + // Initialize crypto handlers + let nip59_handler = Nip59Handler::new(keys.clone()); + // Convert nostr_sdk SecretKey to secp256k1 SecretKey for TokenDecryptor + let secret_bytes = Zeroizing::new(keys.secret_key().to_secret_bytes()); + let mut secp_secret_key = secp256k1::SecretKey::from_slice(secret_bytes.as_ref()) + .context("Failed to create secp256k1 secret key")?; + let token_decryptor = TokenDecryptor::new(&mut secp_secret_key); + + // Initialize push clients + let apns_client = if config.apns.enabled { + match ApnsClient::with_metrics(config.apns.clone(), metrics.clone()).await { + Ok(client) => { + if client.is_configured() { + info!( + environment = %config.apns.environment, + payload_mode = %config.apns.payload_mode, + "APNs push service configured" + ); + Some(client) + } else { + warn!("APNs enabled but not fully configured"); + None + } + } + Err(e) => { + error!(error = %e, "Failed to initialize APNs client"); + None + } + } + } else { + info!("APNs push service disabled"); + None + }; + + let fcm_client = if config.fcm.enabled { + match FcmClient::with_metrics(config.fcm.clone(), metrics.clone()).await { + Ok(client) => { + if client.is_configured() { + info!("FCM push service configured"); + Some(client) + } else { + warn!("FCM enabled but not fully configured"); + None + } + } + Err(e) => { + error!(error = %e, "Failed to initialize FCM client"); + None + } + } + } else { + info!("FCM push service disabled"); + None + }; + + // Create push dispatcher + let push_dispatcher = Arc::new(PushDispatcher::with_metrics( + apns_client, + fcm_client, + metrics.clone(), + )); + + if !push_dispatcher.is_ready() { + warn!("No push services configured - notifications will not be sent"); + } + + // Create the event processor with configured replay protection and rate + // limiting. Constructed before any relay network work so the event + // consumer can start polling the moment the notification receiver exists + // (see below). + let rate_limit_config = + crate::nostr::events::TokenRateLimitConfig::from_server_config(&config.server); + let replay_config = + crate::nostr::events::ReplayProtectionConfig::from_server_config(&config.server); + let event_processor = Arc::new( + EventProcessor::with_replay_config( + nip59_handler, + token_decryptor, + push_dispatcher.clone(), + rate_limit_config, + replay_config, + metrics.clone(), + ) + .context("Failed to initialize event replay protection")?, + ); + + // Initialize relay client + let relay_client = Arc::new( + RelayClient::with_metrics(keys.clone(), config.relays.clone(), metrics.clone()) + .await + .context("Failed to create relay client")?, + ); + + // Initialize shutdown handler before connecting so a SIGTERM/SIGINT during + // startup exits promptly. Without this, signal handlers were installed only + // after `connect()` returned, so a signal received while waiting for relays + // (potentially up to the whole connection timeout) was ignored. + let shutdown = ShutdownHandler::new(); + + // Bind the health server before any relay work so a bind failure — almost + // always a permanent misconfiguration — fails startup fast instead of + // leaving a process running with dead /health, /ready, and /metrics + // endpoints. + let health_server = HealthServer::new( + config.health.clone(), + relay_client.clone(), + push_dispatcher.clone(), + metrics.clone(), + ); + let health_listener = health_server + .bind() + .await + .context("Failed to start health server")?; + + // Serve under supervision: a runtime health-server failure triggers global + // shutdown so the orchestrator restarts the process instead of leaving it + // running with no external health signal. + let health_shutdown = shutdown.subscribe(); + let health_trigger = shutdown.trigger_handle(); + let health_handle = tokio::spawn(async move { + let result = health_server.serve(health_listener, health_shutdown).await; + supervise_critical_task("health server", result, &health_trigger); + }); + + // Connect to relays, but bail out immediately if a shutdown signal (or an + // internal trigger from a supervised task) arrives while we are still + // waiting for the first relay to connect. + match run_startup_or_shutdown( + relay_client.connect(), + shutdown.wait_for_signal_or_trigger(), + ) + .await + { + StartupOutcome::Connected(result) => { + result.context("Failed to connect to relays")?; + } + StartupOutcome::ShutdownRequested(reason) => { + info!("Shutdown signal received during startup; stopping before relay connection"); + if let Err(e) = relay_client.disconnect().await { + warn!(error = %e, "Error disconnecting from relays during startup shutdown"); + } + // The shutdown watch is already set, so the health task exits on + // its own; join it to finish teardown cleanly. + let _ = health_handle.await; + info!("Transponder stopped"); + return shutdown_result(reason); + } + } + + // Obtain the broadcast receiver BEFORE issuing the subscription REQ. + // + // `notifications()` returns a fresh `tokio::sync::broadcast::Receiver`, which + // only observes messages broadcast after it is created. The subscription uses a + // 2-day lookback, so relays immediately stream the stored backlog of gift wraps. + // If the receiver were created after `subscribe()` (e.g. inside the spawned event + // task), any backlog delivered before the task is first polled would be broadcast + // to zero receivers and silently dropped — broadcast channels do not replay + // history. Creating the receiver first closes that startup window entirely. + let notifications = relay_client.notifications(); + + // Spawn the event consumer BEFORE `subscribe()` streams the backlog and + // before the `publish_inbox_relays()` network round-trip below. The early + // receiver above only prevents the "zero receivers" drop; a consumer must + // also be POLLING before any startup await that can block for a meaningful + // duration, or the backlog can overflow the bounded broadcast buffer while + // nothing drains it and the oldest gift wraps are silently lost. + // + // The loop runs under supervision: an unexpected exit (notification + // channel closed) triggers global shutdown instead of leaving a zombie + // process that reports healthy while processing nothing. + let event_permits = event_processing_permits(config.server.max_concurrent_event_processing); + let event_semaphore = Arc::new(Semaphore::new(event_permits)); + let event_tasks = TaskTracker::new(); + let event_shutdown = shutdown.subscribe(); + let event_trigger = shutdown.trigger_handle(); + let event_handle = { + let processor = event_processor.clone(); + let event_tasks = event_tasks.clone(); + let event_metrics = metrics.clone(); + tokio::spawn(async move { + let exit = run_event_loop( + notifications, + event_shutdown, + event_semaphore, + processor, + event_tasks, + event_metrics, + ) + .await; + supervise_critical_task( + "event loop", + event_loop_exit_to_supervision_result(exit), + &event_trigger, + ); + }) + }; + + // Subscribe to events + relay_client + .subscribe(keys.public_key()) + .await + .context("Failed to subscribe to events")?; + + // Publish inbox relay list + if let Err(e) = relay_client.publish_inbox_relays().await { + warn!(error = %e, "Failed to publish inbox relay list"); + } + + // Start periodic cleanup task + let mut cleanup_shutdown = shutdown.subscribe(); + let event_processor_cleanup = event_processor.clone(); + + let cleanup_handle = tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + + loop { + tokio::select! { + _ = cleanup_shutdown.changed() => { + break; + } + _ = interval.tick() => { + event_processor_cleanup.cleanup().await; + } + } + } + }); + + // Start the background relay-status refresher. After startup it is the + // only writer of the cached relay status (and the relays_connected + // gauges): `/ready` and other readers consume the snapshot without + // recomputing it, so unauthenticated probes cannot drive lock or gauge + // churn (see RelayClient::refresh_status). + let mut status_refresh_shutdown = shutdown.subscribe(); + let status_refresh_client = relay_client.clone(); + let status_refresh_handle = tokio::spawn(async move { + let mut interval = tokio::time::interval(RELAY_STATUS_REFRESH_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = status_refresh_shutdown.changed() => { + break; + } + _ = interval.tick() => { + status_refresh_client.refresh_status().await; + } + } + } + }); + + info!("Transponder running"); + + // Wait for a shutdown signal or an internal trigger from a supervised + // task (health-server failure, notification channel close). + let shutdown_reason = shutdown.wait_for_signal_or_trigger().await; + + info!("Initiating graceful shutdown"); + + // Wait for the full shutdown sequence under one deadline. If an early step + // consumes the budget, `graceful_shutdown` drops the future and later cleanup + // steps are skipped rather than extending the configured shutdown bound. + crate::shutdown::graceful_shutdown( + || { + staged_teardown( + event_handle, + event_tasks, + push_dispatcher, + relay_client, + health_handle, + cleanup_handle, + status_refresh_handle, + ) + }, + config.server.shutdown_timeout_secs, + ) + .await; + + info!("Transponder stopped"); + shutdown_result(shutdown_reason) +} + +fn create_private_key_file(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + options + .open(path) + .with_context(|| format!("Failed to create private key file {}", path.display())) +} + +fn write_private_key_file(path: &Path, secret_hex: &str) -> Result<()> { + let mut file = create_private_key_file(path)?; + file.write_all(secret_hex.as_bytes()) + .with_context(|| format!("Failed to write private key file {}", path.display()))?; + file.write_all(b"\n") + .with_context(|| format!("Failed to write private key file {}", path.display()))?; + file.sync_all() + .with_context(|| format!("Failed to sync private key file {}", path.display())) +} + +/// Generate a new Nostr key pair. +pub fn generate_keys(output: Option<&Path>, show_private_key: bool) -> Result<()> { + let keys = Keys::generate(); + let secret_hex = Zeroizing::new(keys.secret_key().to_secret_hex()); + + if let Some(path) = output { + write_private_key_file(path, secret_hex.as_str())?; + } + + println!("Generated new Nostr key pair:\n"); + println!("Public key (hex): {}", keys.public_key().to_hex()); + println!("Public key (npub): {}", keys.public_key().to_bech32()?); + println!(); + + if show_private_key { + eprintln!( + "WARNING: The private key below enables decryption of ALL notification tokens. Do not share, log, or commit it." + ); + println!("Private key (hex): {}", secret_hex.as_str()); + println!(); + } + + if let Some(path) = output { + println!("Secret written to: {}", path.display()); + println!("Configure the server with:"); + println!(" [server]"); + println!(" private_key_file = \"{}\"", path.display()); + } else if !show_private_key { + println!("Secret material was not printed."); + println!("Store a fresh secret directly in a restricted file:"); + println!(" transponder generate-keys --output /path/to/transponder-server.key"); + println!("Use --show-private-key only in a secure, non-logged terminal."); + } + + println!(); + println!("Share the public key (hex or npub) with clients so they can"); + println!("encrypt notification tokens for your server."); + + Ok(()) +} + +/// Refuse to load a private key file whose permissions grant group/other access. +/// +/// Mirrors the `0600` mode enforced by the `generate-keys` write path (and the +/// ssh/gpg convention): the server private key decrypts every notification +/// token, so a group/world-readable key file is a standing compromise. Failing +/// startup makes the exposure visible instead of silently loading the key. +#[cfg(unix)] +pub fn verify_private_key_file_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + // A missing or unreadable file falls through to the read below, which + // reports the canonical "Failed to read server private key file" error. + let Ok(metadata) = fs::metadata(path) else { + return Ok(()); + }; + + let mode = metadata.permissions().mode() & 0o777; + if mode & 0o077 != 0 { + anyhow::bail!( + "Refusing to load server private key file {path}: permissions {mode:03o} allow group/other access; restrict with `chmod 600 {path}`", + path = path.display(), + mode = mode + ); + } + + Ok(()) +} + +/// Resolve the server private key from config or a mounted secret file. +pub fn resolve_server_private_key( + config: &mut crate::config::ServerConfig, +) -> Result> { + let private_key = config.private_key.trim(); + if !private_key.is_empty() { + let private_key = Zeroizing::new(std::mem::take(&mut *config.private_key)); + + return if private_key.trim().len() == private_key.len() { + Ok(private_key) + } else { + Ok(Zeroizing::new(private_key.trim().to_string())) + }; + } + + let private_key_file = config.private_key_file.trim(); + if private_key_file.is_empty() { + return Ok(Zeroizing::new(String::new())); + } + + let key_path = Path::new(private_key_file); + + #[cfg(unix)] + verify_private_key_file_permissions(key_path)?; + + let key = Zeroizing::new(fs::read_to_string(key_path).with_context(|| { + format!( + "Failed to read server private key file {}", + key_path.display() + ) + })?); + + if key.trim().len() == key.len() { + Ok(key) + } else { + Ok(Zeroizing::new(key.trim().to_string())) + } +} + +/// Probe a Transponder health endpoint and return a non-zero exit on failure. +pub async fn run_healthcheck(url: &str) -> Result<()> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .context("Failed to create healthcheck client")?; + + let response = client + .get(url) + .send() + .await + .with_context(|| format!("Failed to reach health endpoint at {url}"))?; + + if response.status().is_success() { + return Ok(()); + } + + anyhow::bail!("Healthcheck failed with status {}", response.status()) +} + +fn configured_logging_filter(level: &str) -> Result { + EnvFilter::try_new(level).with_context(|| format!("invalid logging.level filter: {level}")) +} + +fn logging_filter(config: &crate::config::LoggingConfig) -> Result { + logging_filter_from_env(config, std::env::var(EnvFilter::DEFAULT_ENV)) +} + +fn logging_filter_from_env( + config: &crate::config::LoggingConfig, + env_filter: std::result::Result, +) -> Result { + match env_filter { + Ok(env_filter) => EnvFilter::try_new(&env_filter) + .with_context(|| format!("invalid RUST_LOG filter: {env_filter}")), + Err(std::env::VarError::NotPresent) => configured_logging_filter(&config.level), + Err(error) => Err(error).context("invalid RUST_LOG environment variable"), + } +} + +/// Initialize the tracing subscriber based on configuration. +pub fn init_logging(config: &crate::config::LoggingConfig) -> Result<()> { + let filter = logging_filter(config)?; + + // `logging.format` is a validated enum, so there is no silent fallthrough + // for typos and no undocumented "off" blackout arm: silencing console + // output is `logging.level = "off"`, which keeps the subscriber installed. + // The `EnvFilter` is attached per-layer to the fmt layer only, so it never + // gates the GlitchTip layer: error reporting stays independent of console + // verbosity — a tightened `RUST_LOG` or `level = "off"` does not silence + // it. The GlitchTip layer carries its own ERROR-level filter (see + // `telemetry`). + let console_layer: Box + Send + Sync> = + match config.format { + crate::config::LogFormat::Json => fmt::layer().json().with_filter(filter).boxed(), + crate::config::LogFormat::Pretty => fmt::layer().pretty().with_filter(filter).boxed(), + }; + + tracing_subscriber::registry() + .with(console_layer) + .with(crate::telemetry::glitchtip_layer()) + .init(); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{Router, http::StatusCode, routing::get}; + use std::io::Write; + use std::path::PathBuf; + use tempfile::NamedTempFile; + use tokio::net::TcpListener; + + #[test] + fn event_processing_permits_clamps_zero_to_one() { + assert_eq!(event_processing_permits(0), 1); + } + + #[test] + fn event_processing_permits_preserves_positive_values() { + assert_eq!(event_processing_permits(1), 1); + assert_eq!(event_processing_permits(64), 64); + assert_eq!(event_processing_permits(1000), 1000); + } + + #[tokio::test] + async fn event_semaphore_caps_in_flight_permits() { + let permits = event_processing_permits(3); + let semaphore = Arc::new(Semaphore::new(permits)); + + // Acquire up to the cap; all succeed. + let p1 = Arc::clone(&semaphore).acquire_owned().await.unwrap(); + let _p2 = Arc::clone(&semaphore).acquire_owned().await.unwrap(); + let _p3 = Arc::clone(&semaphore).acquire_owned().await.unwrap(); + assert_eq!(semaphore.available_permits(), 0); + + // No permit is available while the cap is reached. + assert!(Arc::clone(&semaphore).try_acquire_owned().is_err()); + + // Releasing one permit frees a slot for the next event. + drop(p1); + assert_eq!(semaphore.available_permits(), 1); + assert!(Arc::clone(&semaphore).try_acquire_owned().is_ok()); + } + + #[tokio::test] + async fn event_permit_wait_acquires_available_permit() { + let semaphore = Arc::new(Semaphore::new(1)); + let (_shutdown_tx, mut shutdown_rx) = watch::channel(false); + + let permit = + acquire_event_processing_permit_or_shutdown(Arc::clone(&semaphore), &mut shutdown_rx) + .await + .expect("available permit should be acquired"); + + assert_eq!(semaphore.available_permits(), 0); + drop(permit); + assert_eq!(semaphore.available_permits(), 1); + } + + #[tokio::test] + async fn event_permit_wait_exits_when_shutdown_arrives() { + let semaphore = Arc::new(Semaphore::new(1)); + let _held_permit = Arc::clone(&semaphore).acquire_owned().await.unwrap(); + let (shutdown_tx, mut shutdown_rx) = watch::channel(false); + + let permit_wait = + acquire_event_processing_permit_or_shutdown(Arc::clone(&semaphore), &mut shutdown_rx); + tokio::pin!(permit_wait); + + let pending_before_shutdown = + tokio::time::timeout(Duration::from_millis(10), &mut permit_wait).await; + assert!(pending_before_shutdown.is_err()); + + shutdown_tx.send(true).unwrap(); + + let result = tokio::time::timeout(Duration::from_secs(1), &mut permit_wait) + .await + .expect("shutdown should interrupt a saturated permit wait"); + + assert!(result.is_none()); + assert_eq!(semaphore.available_permits(), 0); + } + + #[tokio::test] + async fn run_startup_or_shutdown_returns_connected_when_startup_finishes_first() { + // A signal future that never resolves models no signal arriving; the + // startup future's result must be surfaced verbatim. + let outcome = run_startup_or_shutdown( + async { Ok::<(), anyhow::Error>(()) }, + std::future::pending::<()>(), + ) + .await; + + assert!(matches!(outcome, StartupOutcome::Connected(Ok(())))); + } + + #[tokio::test] + async fn run_startup_or_shutdown_surfaces_startup_error() { + let outcome = run_startup_or_shutdown( + async { Err::<(), anyhow::Error>(anyhow::anyhow!("connect failed")) }, + std::future::pending::<()>(), + ) + .await; + + match outcome { + StartupOutcome::Connected(Err(error)) => { + assert_eq!(error.to_string(), "connect failed"); + } + other => panic!("expected a surfaced startup error, got {other:?}"), + } + } + + #[tokio::test] + async fn run_startup_or_shutdown_returns_shutdown_when_signal_arrives_first() { + // Startup never finishes, so only the already-ready signal can win. + // The signal future's output (here the shutdown reason) is carried + // through so the caller can map it to the process exit result. + let outcome = run_startup_or_shutdown( + std::future::pending::>(), + std::future::ready(crate::shutdown::ShutdownReason::Signal), + ) + .await; + + assert!(matches!( + outcome, + StartupOutcome::ShutdownRequested(crate::shutdown::ShutdownReason::Signal) + )); + } + + #[tokio::test] + async fn run_startup_or_shutdown_prefers_shutdown_when_both_ready() { + // When both futures are ready in the same poll, the `biased` select must + // prefer shutdown so a pending signal is never masked by a connect that + // resolves simultaneously. + let outcome = run_startup_or_shutdown( + std::future::ready(Ok::<(), anyhow::Error>(())), + std::future::ready(()), + ) + .await; + + assert!(matches!(outcome, StartupOutcome::ShutdownRequested(()))); + } + + #[test] + fn shutdown_result_treats_signal_as_clean_exit() { + assert!(shutdown_result(crate::shutdown::ShutdownReason::Signal).is_ok()); + } + + #[test] + fn shutdown_result_treats_internal_trigger_as_failure() { + let error = shutdown_result(crate::shutdown::ShutdownReason::InternalTrigger) + .expect_err("an internally triggered shutdown must exit non-zero"); + + assert!(error.to_string().contains("critical task failure")); + } + + #[test] + fn notification_receive_lag_is_recoverable() { + let action = classify_notification_receive_error(&RecvError::Lagged(3)); + + assert_eq!(action, NotificationReceiveAction::Continue); + } + + #[test] + fn notification_receive_close_is_terminal() { + let action = classify_notification_receive_error(&RecvError::Closed); + + assert_eq!(action, NotificationReceiveAction::Shutdown); + } + + #[test] + fn notification_receive_lag_records_metrics() { + let metrics = Metrics::new().unwrap(); + + record_notification_receive_metrics(Some(&metrics), &RecvError::Lagged(3)); + + assert_eq!(metrics.relay_notifications_lagged_total.get(), 1); + assert_eq!(metrics.relay_notifications_dropped_total.get(), 3); + } + + #[test] + fn notification_receive_close_does_not_record_metrics() { + let metrics = Metrics::new().unwrap(); + + record_notification_receive_metrics(Some(&metrics), &RecvError::Closed); + + assert_eq!(metrics.relay_notifications_lagged_total.get(), 0); + assert_eq!(metrics.relay_notifications_dropped_total.get(), 0); + } + + fn test_event_processor() -> Arc { + let keys = Keys::generate(); + let nip59_handler = Nip59Handler::new(keys.clone()); + let mut secp_secret_key = + secp256k1::SecretKey::from_slice(&keys.secret_key().to_secret_bytes()) + .expect("valid secret key"); + let token_decryptor = TokenDecryptor::new(&mut secp_secret_key); + let push_dispatcher = Arc::new(PushDispatcher::new(None, None)); + Arc::new(EventProcessor::new( + nip59_handler, + token_decryptor, + push_dispatcher, + )) + } + + fn test_event_notification() -> RelayPoolNotification { + let event = EventBuilder::text_note("task lifecycle test") + .sign_with_keys(&Keys::generate()) + .expect("signable test event"); + RelayPoolNotification::Event { + relay_url: RelayUrl::parse("ws://127.0.0.1:7777").unwrap(), + subscription_id: SubscriptionId::new("test-sub"), + event: Box::new(event), + } + } + + fn test_relay_client_config() -> crate::config::RelayConfig { + crate::config::RelayConfig { + clearnet: vec![], + allow_unencrypted_clearnet_relays: true, + onion: vec![], + reconnect_interval_secs: 5, + max_reconnect_attempts: 10, + connection_timeout_secs: 5, + } + } + + /// Poll until the processor has reserved exactly `expected` event IDs, + /// proving the spawned processing task actually ran. + async fn wait_for_cache_len(processor: &EventProcessor, expected: usize) -> bool { + for _ in 0..100 { + if processor.cache_len() == expected { + return true; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + false + } + + #[test] + fn event_loop_shutdown_exit_is_expected() { + assert!(event_loop_exit_to_supervision_result(EventLoopExit::ShutdownRequested).is_ok()); + } + + #[test] + fn event_loop_channel_close_exit_is_a_supervised_failure() { + let error = event_loop_exit_to_supervision_result(EventLoopExit::ChannelClosed) + .expect_err("channel close must be supervised as a failure"); + + assert!(error.contains("notification channel closed")); + } + + #[test] + fn supervise_critical_task_triggers_shutdown_on_error() { + let handler = ShutdownHandler::new(); + let receiver = handler.subscribe(); + + supervise_critical_task("test task", Err::<(), _>("boom"), &handler.trigger_handle()); + + assert!(*receiver.borrow()); + } + + #[test] + fn supervise_critical_task_leaves_clean_exits_alone() { + let handler = ShutdownHandler::new(); + let receiver = handler.subscribe(); + + supervise_critical_task("test task", Ok::<(), &str>(()), &handler.trigger_handle()); + + assert!(!*receiver.borrow()); + } + + #[tokio::test] + async fn run_event_loop_exits_when_shutdown_signal_fires() { + let (_notification_tx, notifications) = broadcast::channel::(4); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + shutdown_tx.send(true).unwrap(); + + let exit = tokio::time::timeout( + Duration::from_secs(1), + run_event_loop( + notifications, + shutdown_rx, + Arc::new(Semaphore::new(1)), + test_event_processor(), + TaskTracker::new(), + None, + ), + ) + .await + .expect("shutdown signal must end the event loop"); + + assert_eq!(exit, EventLoopExit::ShutdownRequested); + } + + #[tokio::test] + async fn run_event_loop_reports_channel_close_as_unexpected_exit() { + let (notification_tx, notifications) = broadcast::channel::(4); + drop(notification_tx); + let (_shutdown_tx, shutdown_rx) = watch::channel(false); + + let exit = tokio::time::timeout( + Duration::from_secs(1), + run_event_loop( + notifications, + shutdown_rx, + Arc::new(Semaphore::new(1)), + test_event_processor(), + TaskTracker::new(), + None, + ), + ) + .await + .expect("channel close must end the event loop"); + + assert_eq!(exit, EventLoopExit::ChannelClosed); + } + + #[tokio::test] + async fn run_event_loop_processes_events_through_the_task_tracker() { + let (notification_tx, notifications) = broadcast::channel(16); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let semaphore = Arc::new(Semaphore::new(2)); + let processor = test_event_processor(); + let event_tasks = TaskTracker::new(); + + let loop_handle = tokio::spawn(run_event_loop( + notifications, + shutdown_rx, + Arc::clone(&semaphore), + Arc::clone(&processor), + event_tasks.clone(), + None, + )); + + notification_tx.send(test_event_notification()).unwrap(); + + assert!( + wait_for_cache_len(&processor, 1).await, + "the event-processing task must run and reserve the event ID" + ); + + shutdown_tx.send(true).unwrap(); + let exit = tokio::time::timeout(Duration::from_secs(1), loop_handle) + .await + .expect("loop must exit on shutdown") + .expect("loop task must not panic"); + assert_eq!(exit, EventLoopExit::ShutdownRequested); + + // In-flight processing work is tracked, joinable, and releases its + // semaphore permit when done. + event_tasks.close(); + tokio::time::timeout(Duration::from_secs(1), event_tasks.wait()) + .await + .expect("tracked processing tasks must drain"); + assert_eq!(semaphore.available_permits(), 2); + } + + #[tokio::test] + async fn run_event_loop_skips_non_event_notifications() { + let (notification_tx, notifications) = broadcast::channel(4); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let processor = test_event_processor(); + let event_tasks = TaskTracker::new(); + + let loop_handle = tokio::spawn(run_event_loop( + notifications, + shutdown_rx, + Arc::new(Semaphore::new(1)), + Arc::clone(&processor), + event_tasks.clone(), + None, + )); + + notification_tx + .send(RelayPoolNotification::Shutdown) + .unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + + shutdown_tx.send(true).unwrap(); + let exit = tokio::time::timeout(Duration::from_secs(1), loop_handle) + .await + .expect("loop must exit on shutdown") + .expect("loop task must not panic"); + + assert_eq!(exit, EventLoopExit::ShutdownRequested); + assert_eq!( + processor.cache_len(), + 0, + "non-event notifications must not spawn processing work" + ); + assert!(event_tasks.is_empty()); + } + + #[tokio::test] + async fn run_event_loop_continues_after_lagged_notifications() { + // Capacity 1: the second pre-loop send overwrites the first, so the + // loop's first recv yields `Lagged` and must keep consuming. + let (notification_tx, notifications) = broadcast::channel(1); + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let metrics = Metrics::new().unwrap(); + let processor = test_event_processor(); + let event_tasks = TaskTracker::new(); + + notification_tx.send(test_event_notification()).unwrap(); + notification_tx.send(test_event_notification()).unwrap(); + + let loop_handle = tokio::spawn(run_event_loop( + notifications, + shutdown_rx, + Arc::new(Semaphore::new(1)), + Arc::clone(&processor), + event_tasks.clone(), + Some(metrics.clone()), + )); + + // The surviving (newest) event is still processed after the lag. + assert!( + wait_for_cache_len(&processor, 1).await, + "the loop must keep processing after a lag" + ); + assert_eq!(metrics.relay_notifications_lagged_total.get(), 1); + assert_eq!(metrics.relay_notifications_dropped_total.get(), 1); + + shutdown_tx.send(true).unwrap(); + let exit = tokio::time::timeout(Duration::from_secs(1), loop_handle) + .await + .expect("loop must exit on shutdown") + .expect("loop task must not panic"); + assert_eq!(exit, EventLoopExit::ShutdownRequested); + } + + #[tokio::test] + async fn staged_teardown_waits_for_in_flight_event_tasks() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let event_tasks = TaskTracker::new(); + let finished = Arc::new(AtomicBool::new(false)); + let finished_flag = Arc::clone(&finished); + event_tasks.spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + finished_flag.store(true, Ordering::SeqCst); + }); + + let push_dispatcher = Arc::new(PushDispatcher::new(None, None)); + let relay_client = Arc::new( + RelayClient::new(Keys::generate(), test_relay_client_config()) + .await + .unwrap(), + ); + + let event_handle = tokio::spawn(async {}); + let health_handle = tokio::spawn(async {}); + let cleanup_handle = tokio::spawn(async {}); + let status_refresh_handle = tokio::spawn(async {}); + + tokio::time::timeout( + Duration::from_secs(5), + staged_teardown( + event_handle, + event_tasks.clone(), + push_dispatcher, + relay_client, + health_handle, + cleanup_handle, + status_refresh_handle, + ), + ) + .await + .expect("staged teardown must complete"); + + assert!( + finished.load(Ordering::SeqCst), + "in-flight event tasks must finish before teardown completes" + ); + assert!(event_tasks.is_closed()); + } + + #[tokio::test] + async fn staged_teardown_joins_the_event_loop_before_closing_the_tracker() { + use std::sync::atomic::{AtomicBool, Ordering}; + + // Model the shutdown race from the teardown-ordering bug: the event + // loop admits one final event right before it exits. Because teardown + // joins the loop before closing the tracker, that late task is still + // tracked, drained, and its dispatch window stays open. + let event_tasks = TaskTracker::new(); + let late_task_finished = Arc::new(AtomicBool::new(false)); + + let event_handle = { + let event_tasks = event_tasks.clone(); + let finished = Arc::clone(&late_task_finished); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(30)).await; + event_tasks.spawn(async move { + tokio::time::sleep(Duration::from_millis(30)).await; + finished.store(true, Ordering::SeqCst); + }); + }) + }; + + let push_dispatcher = Arc::new(PushDispatcher::new(None, None)); + let relay_client = Arc::new( + RelayClient::new(Keys::generate(), test_relay_client_config()) + .await + .unwrap(), + ); + let health_handle = tokio::spawn(async {}); + let cleanup_handle = tokio::spawn(async {}); + let status_refresh_handle = tokio::spawn(async {}); + + tokio::time::timeout( + Duration::from_secs(5), + staged_teardown( + event_handle, + event_tasks.clone(), + push_dispatcher, + relay_client, + health_handle, + cleanup_handle, + status_refresh_handle, + ), + ) + .await + .expect("staged teardown must complete"); + + assert!( + late_task_finished.load(Ordering::SeqCst), + "a task admitted right before the event loop exits must still be drained" + ); + } + + fn test_logging_config(level: &str) -> crate::config::LoggingConfig { + crate::config::LoggingConfig { + level: level.to_string(), + format: crate::config::LogFormat::Json, + } + } + + #[test] + fn logging_filter_uses_configured_filter_without_env_filter() { + assert!( + logging_filter_from_env( + &test_logging_config("info"), + Err(std::env::VarError::NotPresent) + ) + .is_ok() + ); + } + + #[test] + fn logging_filter_rejects_invalid_configured_filter_without_panic() { + let config = test_logging_config("target=lvl"); + let result = std::panic::catch_unwind(|| { + logging_filter_from_env(&config, Err(std::env::VarError::NotPresent)) + }); + let error = match result.expect("invalid logging.level should not panic") { + Ok(_) => panic!("invalid logging.level should return an error"), + Err(error) => error, + }; + + assert!( + error + .to_string() + .contains("invalid logging.level filter: target=lvl") + ); + } + + #[test] + fn logging_filter_prefers_env_filter_over_invalid_configured_filter() { + assert!( + logging_filter_from_env(&test_logging_config("target=lvl"), Ok("warn".to_string())) + .is_ok() + ); + } + + #[test] + fn logging_filter_rejects_invalid_env_filter_without_using_config() { + let error = + logging_filter_from_env(&test_logging_config("info"), Ok("target=lvl".to_string())) + .expect_err("invalid RUST_LOG should return an error"); + + assert!( + error + .to_string() + .contains("invalid RUST_LOG filter: target=lvl") + ); + } + + #[tokio::test] + async fn healthcheck_command_accepts_success_status() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = Router::new().route("/health", get(|| async { StatusCode::OK })); + + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let result = run_healthcheck(&format!("http://{addr}/health")).await; + + server.abort(); + assert!(result.is_ok()); + } + + #[tokio::test] + async fn healthcheck_command_rejects_error_status() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = Router::new().route( + "/health", + get(|| async { (StatusCode::SERVICE_UNAVAILABLE, "not ready") }), + ); + + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let result = run_healthcheck(&format!("http://{addr}/health")).await; + + server.abort(); + assert!(result.is_err()); + } + + #[tokio::test] + async fn healthcheck_command_reports_unreachable_endpoint() { + let result = run_healthcheck("http://127.0.0.1:1/health").await; + + let error = result.expect_err("healthcheck should fail for unreachable endpoints"); + assert!( + error + .to_string() + .contains("Failed to reach health endpoint at http://127.0.0.1:1/health") + ); + } + + #[test] + fn resolve_server_private_key_prefers_inline_value() { + let mut config = crate::config::ServerConfig { + private_key: Zeroizing::new("abc123".to_string()), + private_key_file: "/tmp/ignored".to_string(), + shutdown_timeout_secs: 10, + max_dedup_cache_size: 100_000, + dedup_state_path: PathBuf::new(), + dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, + max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, + max_notification_future_skew_secs: + crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, + max_rate_limit_cache_size: 100_000, + max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, + encrypted_token_rate_limit_per_minute: 240, + encrypted_token_rate_limit_per_hour: 5000, + device_token_rate_limit_per_minute: 240, + device_token_rate_limit_per_hour: 5000, + max_concurrent_event_processing: 64, + global_unwrap_rate_limit_per_minute: 600, + global_unwrap_rate_limit_per_hour: 30_000, + }; + + assert_eq!( + resolve_server_private_key(&mut config).unwrap().as_str(), + "abc123" + ); + assert_eq!(config.private_key.as_str(), ""); + } + + #[test] + fn resolve_server_private_key_reads_secret_file() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "abc123").unwrap(); + + let mut config = crate::config::ServerConfig { + private_key: Zeroizing::new(String::new()), + private_key_file: file.path().display().to_string(), + shutdown_timeout_secs: 10, + max_dedup_cache_size: 100_000, + dedup_state_path: PathBuf::new(), + dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, + max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, + max_notification_future_skew_secs: + crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, + max_rate_limit_cache_size: 100_000, + max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, + encrypted_token_rate_limit_per_minute: 240, + encrypted_token_rate_limit_per_hour: 5000, + device_token_rate_limit_per_minute: 240, + device_token_rate_limit_per_hour: 5000, + max_concurrent_event_processing: 64, + global_unwrap_rate_limit_per_minute: 600, + global_unwrap_rate_limit_per_hour: 30_000, + }; + + assert_eq!( + resolve_server_private_key(&mut config).unwrap().as_str(), + "abc123" + ); + } + + #[test] + fn resolve_server_private_key_treats_whitespace_inline_value_as_empty() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "abc123").unwrap(); + + let mut config = crate::config::ServerConfig { + private_key: Zeroizing::new(" \n\t ".to_string()), + private_key_file: format!(" {} ", file.path().display()), + shutdown_timeout_secs: 10, + max_dedup_cache_size: 100_000, + dedup_state_path: PathBuf::new(), + dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, + max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, + max_notification_future_skew_secs: + crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, + max_rate_limit_cache_size: 100_000, + max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, + encrypted_token_rate_limit_per_minute: 240, + encrypted_token_rate_limit_per_hour: 5000, + device_token_rate_limit_per_minute: 240, + device_token_rate_limit_per_hour: 5000, + max_concurrent_event_processing: 64, + global_unwrap_rate_limit_per_minute: 600, + global_unwrap_rate_limit_per_hour: 30_000, + }; + + assert_eq!( + resolve_server_private_key(&mut config).unwrap().as_str(), + "abc123" + ); + } + + #[test] + fn resolve_server_private_key_returns_empty_when_unset() { + let mut config = crate::config::ServerConfig { + private_key: Zeroizing::new(String::new()), + private_key_file: String::new(), + shutdown_timeout_secs: 10, + max_dedup_cache_size: 100_000, + dedup_state_path: PathBuf::new(), + dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, + max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, + max_notification_future_skew_secs: + crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, + max_rate_limit_cache_size: 100_000, + max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, + encrypted_token_rate_limit_per_minute: 240, + encrypted_token_rate_limit_per_hour: 5000, + device_token_rate_limit_per_minute: 240, + device_token_rate_limit_per_hour: 5000, + max_concurrent_event_processing: 64, + global_unwrap_rate_limit_per_minute: 600, + global_unwrap_rate_limit_per_hour: 30_000, + }; + + assert_eq!( + resolve_server_private_key(&mut config).unwrap().as_str(), + "" + ); + } + + #[test] + fn resolve_server_private_key_reports_missing_secret_file() { + let mut config = crate::config::ServerConfig { + private_key: Zeroizing::new(String::new()), + private_key_file: "/tmp/definitely-missing-transponder-key".to_string(), + shutdown_timeout_secs: 10, + max_dedup_cache_size: 100_000, + dedup_state_path: PathBuf::new(), + dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, + max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, + max_notification_future_skew_secs: + crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, + max_rate_limit_cache_size: 100_000, + max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, + encrypted_token_rate_limit_per_minute: 240, + encrypted_token_rate_limit_per_hour: 5000, + device_token_rate_limit_per_minute: 240, + device_token_rate_limit_per_hour: 5000, + max_concurrent_event_processing: 64, + global_unwrap_rate_limit_per_minute: 600, + global_unwrap_rate_limit_per_hour: 30_000, + }; + + let error = resolve_server_private_key(&mut config) + .expect_err("missing secret file should return an error"); + assert!( + error + .to_string() + .contains("Failed to read server private key file") + ); + } + + #[test] + fn validate_startup_config_rejects_missing_private_key() { + let relays = crate::config::RelayConfig { + clearnet: vec!["wss://relay.example.com".to_string()], + allow_unencrypted_clearnet_relays: false, + onion: Vec::new(), + reconnect_interval_secs: 5, + max_reconnect_attempts: 10, + connection_timeout_secs: 30, + }; + + let error = validate_startup_config("", &relays) + .expect_err("missing private key should be rejected"); + assert_eq!(error.to_string(), "Server private key is required"); + } + + #[test] + fn validate_startup_config_rejects_missing_relays() { + let relays = crate::config::RelayConfig { + clearnet: Vec::new(), + allow_unencrypted_clearnet_relays: false, + onion: Vec::new(), + reconnect_interval_secs: 5, + max_reconnect_attempts: 10, + connection_timeout_secs: 30, + }; + + let error = validate_startup_config("abc123", &relays) + .expect_err("missing relays should be rejected"); + assert_eq!(error.to_string(), "At least one relay must be configured"); + } + + #[test] + #[cfg(not(feature = "tor"))] + fn validate_startup_config_rejects_onion_relays_without_tor_feature() { + let relays = crate::config::RelayConfig { + clearnet: vec!["wss://relay.example.com".to_string()], + allow_unencrypted_clearnet_relays: false, + onion: vec!["wss://example.onion".to_string()], + reconnect_interval_secs: 5, + max_reconnect_attempts: 10, + connection_timeout_secs: 30, + }; + + let error = validate_startup_config("abc123", &relays) + .expect_err("onion relays should require the tor feature"); + assert_eq!(error.to_string(), "Onion relays require the 'tor' feature"); + } + + #[test] + #[cfg(feature = "tor")] + fn validate_startup_config_accepts_onion_only_relays() { + let relays = crate::config::RelayConfig { + clearnet: Vec::new(), + allow_unencrypted_clearnet_relays: false, + onion: vec!["wss://example.onion".to_string()], + reconnect_interval_secs: 5, + max_reconnect_attempts: 10, + connection_timeout_secs: 30, + }; + + assert!(validate_startup_config("abc123", &relays).is_ok()); + } + + #[test] + fn parse_server_secret_key_accepts_nsec_format() { + let secret_key = Keys::generate().secret_key().to_bech32().unwrap(); + + let parsed_key = parse_server_secret_key(&secret_key).unwrap(); + + assert_eq!(parsed_key.to_bech32().unwrap(), secret_key); + } + + #[test] + fn parse_server_secret_key_accepts_resolved_file_value() { + let secret_key = Keys::generate().secret_key().to_bech32().unwrap(); + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, " {secret_key} ").unwrap(); + + let mut config = crate::config::ServerConfig { + private_key: Zeroizing::new(String::new()), + private_key_file: file.path().display().to_string(), + shutdown_timeout_secs: 10, + max_dedup_cache_size: 100_000, + dedup_state_path: PathBuf::new(), + dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, + max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, + max_notification_future_skew_secs: + crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, + max_rate_limit_cache_size: 100_000, + max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, + encrypted_token_rate_limit_per_minute: 240, + encrypted_token_rate_limit_per_hour: 5000, + device_token_rate_limit_per_minute: 240, + device_token_rate_limit_per_hour: 5000, + max_concurrent_event_processing: 64, + global_unwrap_rate_limit_per_minute: 600, + global_unwrap_rate_limit_per_hour: 30_000, + }; + + let resolved_key = resolve_server_private_key(&mut config).unwrap(); + let parsed_key = parse_server_secret_key(resolved_key.as_str()).unwrap(); + + assert_eq!(parsed_key.to_bech32().unwrap(), secret_key); + } + + // ---- #146: private key file permission check (unix) ---- + + #[cfg(unix)] + fn write_key_file_with_mode(mode: u32) -> NamedTempFile { + use std::os::unix::fs::PermissionsExt; + let file = NamedTempFile::new().unwrap(); + std::fs::write(file.path(), "abc123\n").unwrap(); + let mut perms = std::fs::metadata(file.path()).unwrap().permissions(); + perms.set_mode(mode); + std::fs::set_permissions(file.path(), perms).unwrap(); + file + } + + #[cfg(unix)] + #[test] + fn verify_private_key_file_permissions_accepts_0600() { + let file = write_key_file_with_mode(0o600); + assert!(verify_private_key_file_permissions(file.path()).is_ok()); + } + + #[cfg(unix)] + #[test] + fn verify_private_key_file_permissions_rejects_group_readable() { + let file = write_key_file_with_mode(0o640); + let error = verify_private_key_file_permissions(file.path()) + .expect_err("group-readable key file must be rejected"); + assert!(error.to_string().contains("group/other access"), "{error}"); + } + + #[cfg(unix)] + #[test] + fn verify_private_key_file_permissions_rejects_world_readable() { + let file = write_key_file_with_mode(0o644); + let error = verify_private_key_file_permissions(file.path()) + .expect_err("world-readable key file must be rejected"); + assert!(error.to_string().contains("group/other access"), "{error}"); + } + + #[cfg(unix)] + #[test] + fn resolve_server_private_key_rejects_permissive_key_file() { + // End-to-end: a permissive key file must fail the whole resolve path, + // not just the standalone permission check. + let file = write_key_file_with_mode(0o644); + let mut config = crate::config::ServerConfig { + private_key: Zeroizing::new(String::new()), + private_key_file: file.path().display().to_string(), + shutdown_timeout_secs: 10, + max_dedup_cache_size: 100_000, + dedup_state_path: PathBuf::new(), + dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, + max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, + max_notification_future_skew_secs: + crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, + max_rate_limit_cache_size: 100_000, + max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, + encrypted_token_rate_limit_per_minute: 240, + encrypted_token_rate_limit_per_hour: 5000, + device_token_rate_limit_per_minute: 240, + device_token_rate_limit_per_hour: 5000, + max_concurrent_event_processing: 64, + global_unwrap_rate_limit_per_minute: 600, + global_unwrap_rate_limit_per_hour: 30_000, + }; + + let error = resolve_server_private_key(&mut config) + .expect_err("a group/world-readable key file must refuse to load"); + assert!(error.to_string().contains("group/other access"), "{error}"); + } + + #[cfg(unix)] + #[test] + fn resolve_server_private_key_reads_0600_key_file() { + let file = write_key_file_with_mode(0o600); + let mut config = crate::config::ServerConfig { + private_key: Zeroizing::new(String::new()), + private_key_file: file.path().display().to_string(), + shutdown_timeout_secs: 10, + max_dedup_cache_size: 100_000, + dedup_state_path: PathBuf::new(), + dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, + max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, + max_notification_future_skew_secs: + crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, + max_rate_limit_cache_size: 100_000, + max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, + encrypted_token_rate_limit_per_minute: 240, + encrypted_token_rate_limit_per_hour: 5000, + device_token_rate_limit_per_minute: 240, + device_token_rate_limit_per_hour: 5000, + max_concurrent_event_processing: 64, + global_unwrap_rate_limit_per_minute: 600, + global_unwrap_rate_limit_per_hour: 30_000, + }; + + assert_eq!( + resolve_server_private_key(&mut config).unwrap().as_str(), + "abc123" + ); + } + + // ---- #150 / #142: init_logging accepts both enum variants ---- + + #[test] + fn init_logging_builds_layer_for_each_format() { + // `init_logging` installs a global subscriber (a process-wide, one-shot + // side effect), so exercise the format-selection logic through + // `logging_filter` + the format match instead of calling `init`. Both + // enum variants must yield a filter without error. + for format in [ + crate::config::LogFormat::Json, + crate::config::LogFormat::Pretty, + ] { + let config = crate::config::LoggingConfig { + level: "info".to_string(), + format, + }; + assert!(logging_filter(&config).is_ok()); + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8290f75 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,25 @@ +//! Transponder - MIP-05 Push Notification Server +//! +//! A privacy-preserving push notification server implementing the Marmot MIP-05 +//! specification. + +pub mod app; +pub mod config; +pub mod crypto; +pub mod defaults; +pub mod error; +pub mod metrics; +pub mod nostr; +pub mod push; +pub mod rate_limiter; +pub mod redaction; +pub mod server; +pub mod shutdown; +pub mod telemetry; + +#[cfg(test)] +pub(crate) mod test_metrics; +#[cfg(test)] +pub(crate) mod test_vectors; + +pub use app::run; diff --git a/src/main.rs b/src/main.rs index dc4d8fd..2b51d89 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,54 +1,14 @@ -//! Transponder - MIP-05 Push Notification Server -//! -//! A privacy-preserving push notification server implementing the Marmot MIP-05 -//! specification. Listens for gift-wrapped Nostr events on configured relays, -//! decrypts notification requests, and dispatches silent push notifications -//! to APNs and FCM. +//! Transponder binary entry point. -use std::io::Write; -use std::sync::Arc; -use std::time::Duration; -use std::{ - fs::{self, File, OpenOptions}, - path::{Path, PathBuf}, -}; +use std::path::PathBuf; use anyhow::{Context, Result}; use clap::Parser; -use nostr_sdk::prelude::*; -use tokio::sync::broadcast::{self, error::RecvError}; -use tokio::sync::{OwnedSemaphorePermit, Semaphore, watch}; -use tokio_util::task::TaskTracker; -use tracing::{debug, error, info, warn}; -use tracing_subscriber::{EnvFilter, Layer, fmt, prelude::*}; -use zeroize::Zeroizing; +use tracing::{error, info}; -mod config; -mod crypto; -mod defaults; -mod error; -mod metrics; -mod nostr; -mod push; -mod rate_limiter; -mod redaction; -mod server; -mod shutdown; -mod telemetry; - -#[cfg(test)] -mod test_metrics; -#[cfg(test)] -mod test_vectors; - -use config::AppConfig; -use crypto::{Nip59Handler, TokenDecryptor}; -use metrics::Metrics; -use nostr::client::RelayClient; -use nostr::events::EventProcessor; -use push::{ApnsClient, FcmClient, PushDispatcher}; -use server::HealthServer; -use shutdown::{ShutdownHandler, ShutdownTrigger}; +use transponder::app::{generate_keys, init_logging, run, run_healthcheck}; +use transponder::config::AppConfig; +use transponder::telemetry; /// Transponder - MIP-05 Push Notification Server #[derive(Parser, Debug)] @@ -82,305 +42,10 @@ enum Command { }, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum NotificationReceiveAction { - Continue, - Shutdown, -} - -fn classify_notification_receive_error(error: &RecvError) -> NotificationReceiveAction { - match error { - RecvError::Lagged(_) => NotificationReceiveAction::Continue, - RecvError::Closed => NotificationReceiveAction::Shutdown, - } -} - -fn record_notification_receive_metrics(metrics: Option<&Metrics>, error: &RecvError) { - if let RecvError::Lagged(skipped) = error - && let Some(metrics) = metrics - { - metrics.record_relay_notifications_lagged(); - metrics.record_relay_notifications_dropped(*skipped); - } -} - -#[cfg(feature = "tor")] -const TOR_FEATURE_ENABLED: bool = true; -#[cfg(not(feature = "tor"))] -const TOR_FEATURE_ENABLED: bool = false; - -/// Cadence of the background relay-status refresh. -/// -/// The refresher recomputes the cached relay connection status (and the -/// `relays_connected` gauges) on this fixed interval, so readiness probes can -/// be pure reads of the snapshot and gauge updates track connection state -/// rather than probe traffic. Five seconds keeps `/ready` at most one refresh -/// interval behind real connection changes for typical orchestrator probe -/// periods (10s+) while enumerating the relay pool rarely. -const RELAY_STATUS_REFRESH_INTERVAL: Duration = Duration::from_secs(5); - -fn validate_startup_config(server_private_key: &str, relays: &config::RelayConfig) -> Result<()> { - if server_private_key.is_empty() { - anyhow::bail!("Server private key is required"); - } - - if relays.clearnet.is_empty() && relays.onion.is_empty() { - anyhow::bail!("At least one relay must be configured"); - } - - if !TOR_FEATURE_ENABLED && !relays.onion.is_empty() { - anyhow::bail!("Onion relays require the 'tor' feature"); - } - - Ok(()) -} - -fn parse_server_secret_key(server_private_key: &str) -> Result { - SecretKey::parse(server_private_key).context("Invalid server private key") -} - -/// Number of permits to use for the event-processing semaphore. -/// -/// Bounds total in-flight gift-wrap unwrap (ECDH) work. A configured value of -/// zero would deadlock the event loop (no permit could ever be acquired), so it -/// is clamped up to a single permit, preserving sequential processing. -#[must_use] -fn event_processing_permits(max_concurrent_event_processing: usize) -> usize { - max_concurrent_event_processing.max(1) -} - -/// Outcome of racing relay startup against a shutdown signal. -#[derive(Debug)] -enum StartupOutcome { - /// Startup finished; carries the connect result. - Connected(T), - /// A shutdown signal arrived before startup finished; carries the signal - /// future's output (e.g. the [`shutdown::ShutdownReason`]). - ShutdownRequested(S), -} - -/// Awaits a startup future while remaining responsive to a shutdown signal. -/// -/// The signal future must already have its SIGTERM/SIGINT handlers installed -/// *before* the startup work is awaited, so a signal that arrives while waiting -/// for relay connections exits promptly instead of being ignored until the -/// connect timeout elapses. Shutdown is preferred (`biased`) so a signal that is -/// already pending wins over a startup future that resolves in the same poll. -async fn run_startup_or_shutdown( - startup: StartupFut, - signal_fut: SignalFut, -) -> StartupOutcome -where - StartupFut: std::future::Future, - SignalFut: std::future::Future, -{ - tokio::select! { - biased; - - reason = signal_fut => StartupOutcome::ShutdownRequested(reason), - result = startup => StartupOutcome::Connected(result), - } -} - -/// Map how shutdown was initiated to the process exit result. -/// -/// A signal-initiated stop is a clean exit. An internally triggered stop means -/// a supervised critical task failed; exiting non-zero makes orchestrators -/// with on-failure restart policies reschedule the process instead of -/// treating the stop as intentional. -fn shutdown_result(reason: shutdown::ShutdownReason) -> Result<()> { - match reason { - shutdown::ShutdownReason::Signal => Ok(()), - shutdown::ShutdownReason::InternalTrigger => Err(anyhow::anyhow!( - "shutting down after a critical task failure" - )), - } -} - -async fn acquire_event_processing_permit_or_shutdown( - semaphore: Arc, - shutdown: &mut watch::Receiver, -) -> Option { - // Prefer shutdown over newly available capacity so teardown does not admit - // more event-processing work after a shutdown signal is visible. - tokio::select! { - biased; - - _ = shutdown.changed() => { - debug!("Event processor shutting down before permit acquisition"); - None - }, - permit = semaphore.acquire_owned() => permit.ok(), - } -} - -/// Why the event-consumer loop exited. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum EventLoopExit { - /// The loop observed the shutdown signal; expected during teardown. - ShutdownRequested, - /// The relay notification channel closed while the process was supposed - /// to keep running; unexpected, must tear the process down. - ChannelClosed, -} - -/// Map an event-loop exit to a supervision result. -/// -/// A shutdown-signal exit is the expected teardown path; a channel close means -/// event processing silently died and, left alone, the process would become a -/// zombie that reports healthy while handling zero events (#151). -fn event_loop_exit_to_supervision_result( - exit: EventLoopExit, -) -> std::result::Result<(), &'static str> { - match exit { - EventLoopExit::ShutdownRequested => Ok(()), - EventLoopExit::ChannelClosed => Err("relay notification channel closed"), - } -} - -/// Trigger a process-wide shutdown when a critical task fails. -/// -/// Expected exits pass `Ok(())` and are left alone. Any `Err` means a task the -/// process cannot live without (health server, event loop) died while the rest -/// of the process kept running; triggering shutdown makes the process exit so -/// the orchestrator restarts it instead of leaving a zombie. -fn supervise_critical_task( - task: &'static str, - result: std::result::Result<(), E>, - shutdown_trigger: &ShutdownTrigger, -) { - if let Err(error) = result { - error!(task, error = %error, "Critical task exited unexpectedly; triggering shutdown"); - shutdown_trigger.trigger(); - } -} - -/// Drain relay notifications and process events with bounded concurrency. -/// -/// Each admitted event is processed in its own task spawned onto `event_tasks`, -/// gated by a semaphore. The receive loop drains the broadcast channel quickly -/// so it does not fall behind and trigger `Lagged` overflow, while the -/// semaphore caps total in-flight gift-wrap unwrap (ECDH) work so a flood -/// cannot spawn unbounded crypto tasks. An owned permit is acquired BEFORE -/// spawning and dropped when the spawned task finishes; when the budget is -/// exhausted the loop awaits a free permit (applying back-pressure) while -/// still remaining responsive to shutdown signals. -/// -/// Spawning through the [`TaskTracker`] keeps in-flight unwrap work joinable: -/// [`staged_teardown`] waits for these tasks before draining the push -/// dispatcher, so notifications mid-unwrap at shutdown are delivered instead -/// of aborted (#84, #173). -async fn run_event_loop( - mut notifications: broadcast::Receiver, - mut shutdown: watch::Receiver, - event_semaphore: Arc, - processor: Arc, - event_tasks: TaskTracker, - metrics: Option, -) -> EventLoopExit { - loop { - tokio::select! { - _ = shutdown.changed() => { - info!("Event processor shutting down"); - return EventLoopExit::ShutdownRequested; - } - result = notifications.recv() => { - match result { - Ok(notification) => { - let RelayPoolNotification::Event { event, .. } = notification else { - continue; - }; - - // Acquire a permit before spawning so total in-flight - // unwrap work stays bounded. `None` means shutdown won - // while waiting (or the semaphore closed, which never - // happens here), so intentionally drop this event. - let Some(permit) = acquire_event_processing_permit_or_shutdown( - Arc::clone(&event_semaphore), - &mut shutdown, - ) - .await - else { - return EventLoopExit::ShutdownRequested; - }; - - let processor = processor.clone(); - event_tasks.spawn(async move { - // Hold the permit for the lifetime of the task; it - // is released when `permit` is dropped on return. - let _permit = permit; - if let Err(e) = processor.process(&event).await { - debug!(error = %e, "Event processing error"); - } - }); - } - Err(e) => { - record_notification_receive_metrics(metrics.as_ref(), &e); - - match classify_notification_receive_error(&e) { - NotificationReceiveAction::Continue => { - warn!(error = %e, "Lagged relay notifications, continuing"); - } - NotificationReceiveAction::Shutdown => { - error!(error = %e, "Notification channel closed"); - return EventLoopExit::ChannelClosed; - } - } - } - } - } - } - } -} - -/// Tear down the pipeline in dependency order, producers before consumers. -/// -/// The stage order is load-bearing (#173, #84): -/// -/// 1. Join the event loop. The shutdown watch has already fired, so it stops -/// admitting events; joining it guarantees no new processing task can be -/// spawned after the tracker is closed. -/// 2. Close and drain the task tracker. In-flight gift-wrap unwraps run to -/// completion and their `dispatch()` calls are still accepted, because the -/// push dispatcher has not flipped `shutting_down` yet. -/// 3. Drain the push dispatcher. `wait_for_completion` rejects new dispatches -/// from its first instant, so it must run only after every producer above -/// is quiesced. -/// 4. Disconnect relays. This closes the notification broadcast channel, which -/// is only safe (no spurious `Closed` error) once the event loop is gone. -/// 5. Join the remaining supervised tasks (health server, cleanup, relay -/// status refresher). -/// -/// The caller bounds the whole sequence with the configured shutdown timeout -/// via [`shutdown::graceful_shutdown`]. -async fn staged_teardown( - event_handle: tokio::task::JoinHandle<()>, - event_tasks: TaskTracker, - push_dispatcher: Arc, - relay_client: Arc, - health_handle: tokio::task::JoinHandle<()>, - cleanup_handle: tokio::task::JoinHandle<()>, - status_refresh_handle: tokio::task::JoinHandle<()>, -) { - let _ = event_handle.await; - - event_tasks.close(); - event_tasks.wait().await; - - push_dispatcher.wait_for_completion().await; - - if let Err(e) = relay_client.disconnect().await { - warn!(error = %e, "Error disconnecting from relays"); - } - - let _ = tokio::join!(health_handle, cleanup_handle, status_refresh_handle); -} - #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); - // Handle subcommands if let Some(command) = args.command { return match command { Command::GenerateKeys { @@ -391,18 +56,12 @@ async fn main() -> Result<()> { }; } - // Load configuration let config = AppConfig::load(&args.config) .with_context(|| format!("Failed to load config from {}", args.config))?; - // Initialize error reporting before logging so a GlitchTip client exists for - // the first captured event. The guard is held for the whole process and - // dropped last, flushing buffered events — including any fatal error captured - // below. It must stay in `main` rather than move into `run`, or it would drop - // before that error is recorded. + // The guard must stay in `main` so it outlives `run` and flushes GlitchTip events. let _glitchtip_guard = telemetry::init(&config.glitchtip)?; - // Initialize logging init_logging(&config.logging)?; info!( @@ -417,1540 +76,3 @@ async fn main() -> Result<()> { } result } - -/// Bring the server up and run until shutdown. -/// -/// Split from `main` so a failure during startup or the run loop is logged at -/// `ERROR` — and therefore reported to GlitchTip — instead of only surfacing on -/// process exit. The GlitchTip guard stays in `main` so it outlives this call -/// and flushes the captured event. Failures *before* this point (config load, -/// `telemetry::init`, `init_logging`) occur before the subscriber and client -/// exist, so they surface only on stderr, not in GlitchTip. -async fn run(mut config: AppConfig) -> Result<()> { - // Note: the `metrics.enabled && !health.enabled` case is no longer a - // silent-loss footgun. The health server now binds its listener and serves - // `/metrics` whenever a metrics collector exists — independent of - // `health.enabled` — and emits its own targeted warning at bind time (see - // `server::health::HealthServer::bind`). The #196 rider's structural fix - // landed there, so no separate load-time warning is needed here. - - // Initialize metrics - let metrics = if config.metrics.enabled { - match Metrics::new() { - Ok(m) => { - m.init_server_info(env!("CARGO_PKG_VERSION")); - info!("Metrics initialized"); - Some(m) - } - Err(e) => { - error!(error = %e, "Failed to initialize metrics"); - None - } - } - } else { - info!("Metrics disabled"); - None - }; - - let server_private_key = resolve_server_private_key(&mut config.server)?; - - // Validate configuration - validate_startup_config(server_private_key.as_str(), &config.relays)?; - - // Create server keys - let secret_key = parse_server_secret_key(server_private_key.as_str())?; - let keys = Keys::new(secret_key); - drop(server_private_key); - - debug!( - pubkey = %keys.public_key().to_hex(), - "Server public key" - ); - - // Initialize crypto handlers - let nip59_handler = Nip59Handler::new(keys.clone()); - // Convert nostr_sdk SecretKey to secp256k1 SecretKey for TokenDecryptor - let secret_bytes = Zeroizing::new(keys.secret_key().to_secret_bytes()); - let mut secp_secret_key = secp256k1::SecretKey::from_slice(secret_bytes.as_ref()) - .context("Failed to create secp256k1 secret key")?; - let token_decryptor = TokenDecryptor::new(&mut secp_secret_key); - - // Initialize push clients - let apns_client = if config.apns.enabled { - match ApnsClient::with_metrics(config.apns.clone(), metrics.clone()).await { - Ok(client) => { - if client.is_configured() { - info!( - environment = %config.apns.environment, - payload_mode = %config.apns.payload_mode, - "APNs push service configured" - ); - Some(client) - } else { - warn!("APNs enabled but not fully configured"); - None - } - } - Err(e) => { - error!(error = %e, "Failed to initialize APNs client"); - None - } - } - } else { - info!("APNs push service disabled"); - None - }; - - let fcm_client = if config.fcm.enabled { - match FcmClient::with_metrics(config.fcm.clone(), metrics.clone()).await { - Ok(client) => { - if client.is_configured() { - info!("FCM push service configured"); - Some(client) - } else { - warn!("FCM enabled but not fully configured"); - None - } - } - Err(e) => { - error!(error = %e, "Failed to initialize FCM client"); - None - } - } - } else { - info!("FCM push service disabled"); - None - }; - - // Create push dispatcher - let push_dispatcher = Arc::new(PushDispatcher::with_metrics( - apns_client, - fcm_client, - metrics.clone(), - )); - - if !push_dispatcher.is_ready() { - warn!("No push services configured - notifications will not be sent"); - } - - // Create the event processor with configured replay protection and rate - // limiting. Constructed before any relay network work so the event - // consumer can start polling the moment the notification receiver exists - // (see below). - let rate_limit_config = nostr::events::TokenRateLimitConfig::from_server_config(&config.server); - let replay_config = nostr::events::ReplayProtectionConfig::from_server_config(&config.server); - let event_processor = Arc::new( - EventProcessor::with_replay_config( - nip59_handler, - token_decryptor, - push_dispatcher.clone(), - rate_limit_config, - replay_config, - metrics.clone(), - ) - .context("Failed to initialize event replay protection")?, - ); - - // Initialize relay client - let relay_client = Arc::new( - RelayClient::with_metrics(keys.clone(), config.relays.clone(), metrics.clone()) - .await - .context("Failed to create relay client")?, - ); - - // Initialize shutdown handler before connecting so a SIGTERM/SIGINT during - // startup exits promptly. Without this, signal handlers were installed only - // after `connect()` returned, so a signal received while waiting for relays - // (potentially up to the whole connection timeout) was ignored. - let shutdown = ShutdownHandler::new(); - - // Bind the health server before any relay work so a bind failure — almost - // always a permanent misconfiguration — fails startup fast instead of - // leaving a process running with dead /health, /ready, and /metrics - // endpoints. - let health_server = HealthServer::new( - config.health.clone(), - relay_client.clone(), - push_dispatcher.clone(), - metrics.clone(), - ); - let health_listener = health_server - .bind() - .await - .context("Failed to start health server")?; - - // Serve under supervision: a runtime health-server failure triggers global - // shutdown so the orchestrator restarts the process instead of leaving it - // running with no external health signal. - let health_shutdown = shutdown.subscribe(); - let health_trigger = shutdown.trigger_handle(); - let health_handle = tokio::spawn(async move { - let result = health_server.serve(health_listener, health_shutdown).await; - supervise_critical_task("health server", result, &health_trigger); - }); - - // Connect to relays, but bail out immediately if a shutdown signal (or an - // internal trigger from a supervised task) arrives while we are still - // waiting for the first relay to connect. - match run_startup_or_shutdown( - relay_client.connect(), - shutdown.wait_for_signal_or_trigger(), - ) - .await - { - StartupOutcome::Connected(result) => { - result.context("Failed to connect to relays")?; - } - StartupOutcome::ShutdownRequested(reason) => { - info!("Shutdown signal received during startup; stopping before relay connection"); - if let Err(e) = relay_client.disconnect().await { - warn!(error = %e, "Error disconnecting from relays during startup shutdown"); - } - // The shutdown watch is already set, so the health task exits on - // its own; join it to finish teardown cleanly. - let _ = health_handle.await; - info!("Transponder stopped"); - return shutdown_result(reason); - } - } - - // Obtain the broadcast receiver BEFORE issuing the subscription REQ. - // - // `notifications()` returns a fresh `tokio::sync::broadcast::Receiver`, which - // only observes messages broadcast after it is created. The subscription uses a - // 2-day lookback, so relays immediately stream the stored backlog of gift wraps. - // If the receiver were created after `subscribe()` (e.g. inside the spawned event - // task), any backlog delivered before the task is first polled would be broadcast - // to zero receivers and silently dropped — broadcast channels do not replay - // history. Creating the receiver first closes that startup window entirely. - let notifications = relay_client.notifications(); - - // Spawn the event consumer BEFORE `subscribe()` streams the backlog and - // before the `publish_inbox_relays()` network round-trip below. The early - // receiver above only prevents the "zero receivers" drop; a consumer must - // also be POLLING before any startup await that can block for a meaningful - // duration, or the backlog can overflow the bounded broadcast buffer while - // nothing drains it and the oldest gift wraps are silently lost. - // - // The loop runs under supervision: an unexpected exit (notification - // channel closed) triggers global shutdown instead of leaving a zombie - // process that reports healthy while processing nothing. - let event_permits = event_processing_permits(config.server.max_concurrent_event_processing); - let event_semaphore = Arc::new(Semaphore::new(event_permits)); - let event_tasks = TaskTracker::new(); - let event_shutdown = shutdown.subscribe(); - let event_trigger = shutdown.trigger_handle(); - let event_handle = { - let processor = event_processor.clone(); - let event_tasks = event_tasks.clone(); - let event_metrics = metrics.clone(); - tokio::spawn(async move { - let exit = run_event_loop( - notifications, - event_shutdown, - event_semaphore, - processor, - event_tasks, - event_metrics, - ) - .await; - supervise_critical_task( - "event loop", - event_loop_exit_to_supervision_result(exit), - &event_trigger, - ); - }) - }; - - // Subscribe to events - relay_client - .subscribe(keys.public_key()) - .await - .context("Failed to subscribe to events")?; - - // Publish inbox relay list - if let Err(e) = relay_client.publish_inbox_relays().await { - warn!(error = %e, "Failed to publish inbox relay list"); - } - - // Start periodic cleanup task - let mut cleanup_shutdown = shutdown.subscribe(); - let event_processor_cleanup = event_processor.clone(); - - let cleanup_handle = tokio::spawn(async move { - let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); - - loop { - tokio::select! { - _ = cleanup_shutdown.changed() => { - break; - } - _ = interval.tick() => { - event_processor_cleanup.cleanup().await; - } - } - } - }); - - // Start the background relay-status refresher. After startup it is the - // only writer of the cached relay status (and the relays_connected - // gauges): `/ready` and other readers consume the snapshot without - // recomputing it, so unauthenticated probes cannot drive lock or gauge - // churn (see RelayClient::refresh_status). - let mut status_refresh_shutdown = shutdown.subscribe(); - let status_refresh_client = relay_client.clone(); - let status_refresh_handle = tokio::spawn(async move { - let mut interval = tokio::time::interval(RELAY_STATUS_REFRESH_INTERVAL); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - - loop { - tokio::select! { - _ = status_refresh_shutdown.changed() => { - break; - } - _ = interval.tick() => { - status_refresh_client.refresh_status().await; - } - } - } - }); - - info!("Transponder running"); - - // Wait for a shutdown signal or an internal trigger from a supervised - // task (health-server failure, notification channel close). - let shutdown_reason = shutdown.wait_for_signal_or_trigger().await; - - info!("Initiating graceful shutdown"); - - // Wait for the full shutdown sequence under one deadline. If an early step - // consumes the budget, `graceful_shutdown` drops the future and later cleanup - // steps are skipped rather than extending the configured shutdown bound. - shutdown::graceful_shutdown( - || { - staged_teardown( - event_handle, - event_tasks, - push_dispatcher, - relay_client, - health_handle, - cleanup_handle, - status_refresh_handle, - ) - }, - config.server.shutdown_timeout_secs, - ) - .await; - - info!("Transponder stopped"); - shutdown_result(shutdown_reason) -} - -fn create_private_key_file(path: &Path) -> Result { - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - - options - .open(path) - .with_context(|| format!("Failed to create private key file {}", path.display())) -} - -fn write_private_key_file(path: &Path, secret_hex: &str) -> Result<()> { - let mut file = create_private_key_file(path)?; - file.write_all(secret_hex.as_bytes()) - .with_context(|| format!("Failed to write private key file {}", path.display()))?; - file.write_all(b"\n") - .with_context(|| format!("Failed to write private key file {}", path.display()))?; - file.sync_all() - .with_context(|| format!("Failed to sync private key file {}", path.display())) -} - -/// Generate a new Nostr key pair. -fn generate_keys(output: Option<&Path>, show_private_key: bool) -> Result<()> { - let keys = Keys::generate(); - let secret_hex = Zeroizing::new(keys.secret_key().to_secret_hex()); - - if let Some(path) = output { - write_private_key_file(path, secret_hex.as_str())?; - } - - println!("Generated new Nostr key pair:\n"); - println!("Public key (hex): {}", keys.public_key().to_hex()); - println!("Public key (npub): {}", keys.public_key().to_bech32()?); - println!(); - - if show_private_key { - eprintln!( - "WARNING: The private key below enables decryption of ALL notification tokens. Do not share, log, or commit it." - ); - println!("Private key (hex): {}", secret_hex.as_str()); - println!(); - } - - if let Some(path) = output { - println!("Secret written to: {}", path.display()); - println!("Configure the server with:"); - println!(" [server]"); - println!(" private_key_file = \"{}\"", path.display()); - } else if !show_private_key { - println!("Secret material was not printed."); - println!("Store a fresh secret directly in a restricted file:"); - println!(" transponder generate-keys --output /path/to/transponder-server.key"); - println!("Use --show-private-key only in a secure, non-logged terminal."); - } - - println!(); - println!("Share the public key (hex or npub) with clients so they can"); - println!("encrypt notification tokens for your server."); - - Ok(()) -} - -/// Refuse to load a private key file whose permissions grant group/other access. -/// -/// Mirrors the `0600` mode enforced by the `generate-keys` write path (and the -/// ssh/gpg convention): the server private key decrypts every notification -/// token, so a group/world-readable key file is a standing compromise. Failing -/// startup makes the exposure visible instead of silently loading the key. -#[cfg(unix)] -fn verify_private_key_file_permissions(path: &Path) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - // A missing or unreadable file falls through to the read below, which - // reports the canonical "Failed to read server private key file" error. - let Ok(metadata) = fs::metadata(path) else { - return Ok(()); - }; - - let mode = metadata.permissions().mode() & 0o777; - if mode & 0o077 != 0 { - anyhow::bail!( - "Refusing to load server private key file {path}: permissions {mode:03o} allow group/other access; restrict with `chmod 600 {path}`", - path = path.display(), - mode = mode - ); - } - - Ok(()) -} - -/// Resolve the server private key from config or a mounted secret file. -fn resolve_server_private_key(config: &mut config::ServerConfig) -> Result> { - let private_key = config.private_key.trim(); - if !private_key.is_empty() { - let private_key = Zeroizing::new(std::mem::take(&mut *config.private_key)); - - return if private_key.trim().len() == private_key.len() { - Ok(private_key) - } else { - Ok(Zeroizing::new(private_key.trim().to_string())) - }; - } - - let private_key_file = config.private_key_file.trim(); - if private_key_file.is_empty() { - return Ok(Zeroizing::new(String::new())); - } - - let key_path = Path::new(private_key_file); - - #[cfg(unix)] - verify_private_key_file_permissions(key_path)?; - - let key = Zeroizing::new(fs::read_to_string(key_path).with_context(|| { - format!( - "Failed to read server private key file {}", - key_path.display() - ) - })?); - - if key.trim().len() == key.len() { - Ok(key) - } else { - Ok(Zeroizing::new(key.trim().to_string())) - } -} - -/// Probe a Transponder health endpoint and return a non-zero exit on failure. -async fn run_healthcheck(url: &str) -> Result<()> { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(5)) - .build() - .context("Failed to create healthcheck client")?; - - let response = client - .get(url) - .send() - .await - .with_context(|| format!("Failed to reach health endpoint at {url}"))?; - - if response.status().is_success() { - return Ok(()); - } - - anyhow::bail!("Healthcheck failed with status {}", response.status()) -} - -fn configured_logging_filter(level: &str) -> Result { - EnvFilter::try_new(level).with_context(|| format!("invalid logging.level filter: {level}")) -} - -fn logging_filter(config: &config::LoggingConfig) -> Result { - logging_filter_from_env(config, std::env::var(EnvFilter::DEFAULT_ENV)) -} - -fn logging_filter_from_env( - config: &config::LoggingConfig, - env_filter: std::result::Result, -) -> Result { - match env_filter { - Ok(env_filter) => EnvFilter::try_new(&env_filter) - .with_context(|| format!("invalid RUST_LOG filter: {env_filter}")), - Err(std::env::VarError::NotPresent) => configured_logging_filter(&config.level), - Err(error) => Err(error).context("invalid RUST_LOG environment variable"), - } -} - -/// Initialize the tracing subscriber based on configuration. -fn init_logging(config: &config::LoggingConfig) -> Result<()> { - let filter = logging_filter(config)?; - - // `logging.format` is a validated enum, so there is no silent fallthrough - // for typos and no undocumented "off" blackout arm: silencing console - // output is `logging.level = "off"`, which keeps the subscriber installed. - // The `EnvFilter` is attached per-layer to the fmt layer only, so it never - // gates the GlitchTip layer: error reporting stays independent of console - // verbosity — a tightened `RUST_LOG` or `level = "off"` does not silence - // it. The GlitchTip layer carries its own ERROR-level filter (see - // `telemetry`). - let console_layer: Box + Send + Sync> = - match config.format { - config::LogFormat::Json => fmt::layer().json().with_filter(filter).boxed(), - config::LogFormat::Pretty => fmt::layer().pretty().with_filter(filter).boxed(), - }; - - tracing_subscriber::registry() - .with(console_layer) - .with(telemetry::glitchtip_layer()) - .init(); - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::{Router, http::StatusCode, routing::get}; - use std::io::Write; - use tempfile::NamedTempFile; - use tokio::net::TcpListener; - - #[test] - fn event_processing_permits_clamps_zero_to_one() { - assert_eq!(event_processing_permits(0), 1); - } - - #[test] - fn event_processing_permits_preserves_positive_values() { - assert_eq!(event_processing_permits(1), 1); - assert_eq!(event_processing_permits(64), 64); - assert_eq!(event_processing_permits(1000), 1000); - } - - #[tokio::test] - async fn event_semaphore_caps_in_flight_permits() { - let permits = event_processing_permits(3); - let semaphore = Arc::new(Semaphore::new(permits)); - - // Acquire up to the cap; all succeed. - let p1 = Arc::clone(&semaphore).acquire_owned().await.unwrap(); - let _p2 = Arc::clone(&semaphore).acquire_owned().await.unwrap(); - let _p3 = Arc::clone(&semaphore).acquire_owned().await.unwrap(); - assert_eq!(semaphore.available_permits(), 0); - - // No permit is available while the cap is reached. - assert!(Arc::clone(&semaphore).try_acquire_owned().is_err()); - - // Releasing one permit frees a slot for the next event. - drop(p1); - assert_eq!(semaphore.available_permits(), 1); - assert!(Arc::clone(&semaphore).try_acquire_owned().is_ok()); - } - - #[tokio::test] - async fn event_permit_wait_acquires_available_permit() { - let semaphore = Arc::new(Semaphore::new(1)); - let (_shutdown_tx, mut shutdown_rx) = watch::channel(false); - - let permit = - acquire_event_processing_permit_or_shutdown(Arc::clone(&semaphore), &mut shutdown_rx) - .await - .expect("available permit should be acquired"); - - assert_eq!(semaphore.available_permits(), 0); - drop(permit); - assert_eq!(semaphore.available_permits(), 1); - } - - #[tokio::test] - async fn event_permit_wait_exits_when_shutdown_arrives() { - let semaphore = Arc::new(Semaphore::new(1)); - let _held_permit = Arc::clone(&semaphore).acquire_owned().await.unwrap(); - let (shutdown_tx, mut shutdown_rx) = watch::channel(false); - - let permit_wait = - acquire_event_processing_permit_or_shutdown(Arc::clone(&semaphore), &mut shutdown_rx); - tokio::pin!(permit_wait); - - let pending_before_shutdown = - tokio::time::timeout(Duration::from_millis(10), &mut permit_wait).await; - assert!(pending_before_shutdown.is_err()); - - shutdown_tx.send(true).unwrap(); - - let result = tokio::time::timeout(Duration::from_secs(1), &mut permit_wait) - .await - .expect("shutdown should interrupt a saturated permit wait"); - - assert!(result.is_none()); - assert_eq!(semaphore.available_permits(), 0); - } - - #[tokio::test] - async fn run_startup_or_shutdown_returns_connected_when_startup_finishes_first() { - // A signal future that never resolves models no signal arriving; the - // startup future's result must be surfaced verbatim. - let outcome = run_startup_or_shutdown( - async { Ok::<(), anyhow::Error>(()) }, - std::future::pending::<()>(), - ) - .await; - - assert!(matches!(outcome, StartupOutcome::Connected(Ok(())))); - } - - #[tokio::test] - async fn run_startup_or_shutdown_surfaces_startup_error() { - let outcome = run_startup_or_shutdown( - async { Err::<(), anyhow::Error>(anyhow::anyhow!("connect failed")) }, - std::future::pending::<()>(), - ) - .await; - - match outcome { - StartupOutcome::Connected(Err(error)) => { - assert_eq!(error.to_string(), "connect failed"); - } - other => panic!("expected a surfaced startup error, got {other:?}"), - } - } - - #[tokio::test] - async fn run_startup_or_shutdown_returns_shutdown_when_signal_arrives_first() { - // Startup never finishes, so only the already-ready signal can win. - // The signal future's output (here the shutdown reason) is carried - // through so the caller can map it to the process exit result. - let outcome = run_startup_or_shutdown( - std::future::pending::>(), - std::future::ready(shutdown::ShutdownReason::Signal), - ) - .await; - - assert!(matches!( - outcome, - StartupOutcome::ShutdownRequested(shutdown::ShutdownReason::Signal) - )); - } - - #[tokio::test] - async fn run_startup_or_shutdown_prefers_shutdown_when_both_ready() { - // When both futures are ready in the same poll, the `biased` select must - // prefer shutdown so a pending signal is never masked by a connect that - // resolves simultaneously. - let outcome = run_startup_or_shutdown( - std::future::ready(Ok::<(), anyhow::Error>(())), - std::future::ready(()), - ) - .await; - - assert!(matches!(outcome, StartupOutcome::ShutdownRequested(()))); - } - - #[test] - fn shutdown_result_treats_signal_as_clean_exit() { - assert!(shutdown_result(shutdown::ShutdownReason::Signal).is_ok()); - } - - #[test] - fn shutdown_result_treats_internal_trigger_as_failure() { - let error = shutdown_result(shutdown::ShutdownReason::InternalTrigger) - .expect_err("an internally triggered shutdown must exit non-zero"); - - assert!(error.to_string().contains("critical task failure")); - } - - #[test] - fn notification_receive_lag_is_recoverable() { - let action = classify_notification_receive_error(&RecvError::Lagged(3)); - - assert_eq!(action, NotificationReceiveAction::Continue); - } - - #[test] - fn notification_receive_close_is_terminal() { - let action = classify_notification_receive_error(&RecvError::Closed); - - assert_eq!(action, NotificationReceiveAction::Shutdown); - } - - #[test] - fn notification_receive_lag_records_metrics() { - let metrics = Metrics::new().unwrap(); - - record_notification_receive_metrics(Some(&metrics), &RecvError::Lagged(3)); - - assert_eq!(metrics.relay_notifications_lagged_total.get(), 1); - assert_eq!(metrics.relay_notifications_dropped_total.get(), 3); - } - - #[test] - fn notification_receive_close_does_not_record_metrics() { - let metrics = Metrics::new().unwrap(); - - record_notification_receive_metrics(Some(&metrics), &RecvError::Closed); - - assert_eq!(metrics.relay_notifications_lagged_total.get(), 0); - assert_eq!(metrics.relay_notifications_dropped_total.get(), 0); - } - - fn test_event_processor() -> Arc { - let keys = Keys::generate(); - let nip59_handler = Nip59Handler::new(keys.clone()); - let mut secp_secret_key = - secp256k1::SecretKey::from_slice(&keys.secret_key().to_secret_bytes()) - .expect("valid secret key"); - let token_decryptor = TokenDecryptor::new(&mut secp_secret_key); - let push_dispatcher = Arc::new(PushDispatcher::new(None, None)); - Arc::new(EventProcessor::new( - nip59_handler, - token_decryptor, - push_dispatcher, - )) - } - - fn test_event_notification() -> RelayPoolNotification { - let event = EventBuilder::text_note("task lifecycle test") - .sign_with_keys(&Keys::generate()) - .expect("signable test event"); - RelayPoolNotification::Event { - relay_url: RelayUrl::parse("ws://127.0.0.1:7777").unwrap(), - subscription_id: SubscriptionId::new("test-sub"), - event: Box::new(event), - } - } - - fn test_relay_client_config() -> config::RelayConfig { - config::RelayConfig { - clearnet: vec![], - allow_unencrypted_clearnet_relays: true, - onion: vec![], - reconnect_interval_secs: 5, - max_reconnect_attempts: 10, - connection_timeout_secs: 5, - } - } - - /// Poll until the processor has reserved exactly `expected` event IDs, - /// proving the spawned processing task actually ran. - async fn wait_for_cache_len(processor: &EventProcessor, expected: usize) -> bool { - for _ in 0..100 { - if processor.cache_len() == expected { - return true; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - false - } - - #[test] - fn event_loop_shutdown_exit_is_expected() { - assert!(event_loop_exit_to_supervision_result(EventLoopExit::ShutdownRequested).is_ok()); - } - - #[test] - fn event_loop_channel_close_exit_is_a_supervised_failure() { - let error = event_loop_exit_to_supervision_result(EventLoopExit::ChannelClosed) - .expect_err("channel close must be supervised as a failure"); - - assert!(error.contains("notification channel closed")); - } - - #[test] - fn supervise_critical_task_triggers_shutdown_on_error() { - let handler = ShutdownHandler::new(); - let receiver = handler.subscribe(); - - supervise_critical_task("test task", Err::<(), _>("boom"), &handler.trigger_handle()); - - assert!(*receiver.borrow()); - } - - #[test] - fn supervise_critical_task_leaves_clean_exits_alone() { - let handler = ShutdownHandler::new(); - let receiver = handler.subscribe(); - - supervise_critical_task("test task", Ok::<(), &str>(()), &handler.trigger_handle()); - - assert!(!*receiver.borrow()); - } - - #[tokio::test] - async fn run_event_loop_exits_when_shutdown_signal_fires() { - let (_notification_tx, notifications) = broadcast::channel::(4); - let (shutdown_tx, shutdown_rx) = watch::channel(false); - shutdown_tx.send(true).unwrap(); - - let exit = tokio::time::timeout( - Duration::from_secs(1), - run_event_loop( - notifications, - shutdown_rx, - Arc::new(Semaphore::new(1)), - test_event_processor(), - TaskTracker::new(), - None, - ), - ) - .await - .expect("shutdown signal must end the event loop"); - - assert_eq!(exit, EventLoopExit::ShutdownRequested); - } - - #[tokio::test] - async fn run_event_loop_reports_channel_close_as_unexpected_exit() { - let (notification_tx, notifications) = broadcast::channel::(4); - drop(notification_tx); - let (_shutdown_tx, shutdown_rx) = watch::channel(false); - - let exit = tokio::time::timeout( - Duration::from_secs(1), - run_event_loop( - notifications, - shutdown_rx, - Arc::new(Semaphore::new(1)), - test_event_processor(), - TaskTracker::new(), - None, - ), - ) - .await - .expect("channel close must end the event loop"); - - assert_eq!(exit, EventLoopExit::ChannelClosed); - } - - #[tokio::test] - async fn run_event_loop_processes_events_through_the_task_tracker() { - let (notification_tx, notifications) = broadcast::channel(16); - let (shutdown_tx, shutdown_rx) = watch::channel(false); - let semaphore = Arc::new(Semaphore::new(2)); - let processor = test_event_processor(); - let event_tasks = TaskTracker::new(); - - let loop_handle = tokio::spawn(run_event_loop( - notifications, - shutdown_rx, - Arc::clone(&semaphore), - Arc::clone(&processor), - event_tasks.clone(), - None, - )); - - notification_tx.send(test_event_notification()).unwrap(); - - assert!( - wait_for_cache_len(&processor, 1).await, - "the event-processing task must run and reserve the event ID" - ); - - shutdown_tx.send(true).unwrap(); - let exit = tokio::time::timeout(Duration::from_secs(1), loop_handle) - .await - .expect("loop must exit on shutdown") - .expect("loop task must not panic"); - assert_eq!(exit, EventLoopExit::ShutdownRequested); - - // In-flight processing work is tracked, joinable, and releases its - // semaphore permit when done. - event_tasks.close(); - tokio::time::timeout(Duration::from_secs(1), event_tasks.wait()) - .await - .expect("tracked processing tasks must drain"); - assert_eq!(semaphore.available_permits(), 2); - } - - #[tokio::test] - async fn run_event_loop_skips_non_event_notifications() { - let (notification_tx, notifications) = broadcast::channel(4); - let (shutdown_tx, shutdown_rx) = watch::channel(false); - let processor = test_event_processor(); - let event_tasks = TaskTracker::new(); - - let loop_handle = tokio::spawn(run_event_loop( - notifications, - shutdown_rx, - Arc::new(Semaphore::new(1)), - Arc::clone(&processor), - event_tasks.clone(), - None, - )); - - notification_tx - .send(RelayPoolNotification::Shutdown) - .unwrap(); - tokio::time::sleep(Duration::from_millis(50)).await; - - shutdown_tx.send(true).unwrap(); - let exit = tokio::time::timeout(Duration::from_secs(1), loop_handle) - .await - .expect("loop must exit on shutdown") - .expect("loop task must not panic"); - - assert_eq!(exit, EventLoopExit::ShutdownRequested); - assert_eq!( - processor.cache_len(), - 0, - "non-event notifications must not spawn processing work" - ); - assert!(event_tasks.is_empty()); - } - - #[tokio::test] - async fn run_event_loop_continues_after_lagged_notifications() { - // Capacity 1: the second pre-loop send overwrites the first, so the - // loop's first recv yields `Lagged` and must keep consuming. - let (notification_tx, notifications) = broadcast::channel(1); - let (shutdown_tx, shutdown_rx) = watch::channel(false); - let metrics = Metrics::new().unwrap(); - let processor = test_event_processor(); - let event_tasks = TaskTracker::new(); - - notification_tx.send(test_event_notification()).unwrap(); - notification_tx.send(test_event_notification()).unwrap(); - - let loop_handle = tokio::spawn(run_event_loop( - notifications, - shutdown_rx, - Arc::new(Semaphore::new(1)), - Arc::clone(&processor), - event_tasks.clone(), - Some(metrics.clone()), - )); - - // The surviving (newest) event is still processed after the lag. - assert!( - wait_for_cache_len(&processor, 1).await, - "the loop must keep processing after a lag" - ); - assert_eq!(metrics.relay_notifications_lagged_total.get(), 1); - assert_eq!(metrics.relay_notifications_dropped_total.get(), 1); - - shutdown_tx.send(true).unwrap(); - let exit = tokio::time::timeout(Duration::from_secs(1), loop_handle) - .await - .expect("loop must exit on shutdown") - .expect("loop task must not panic"); - assert_eq!(exit, EventLoopExit::ShutdownRequested); - } - - #[tokio::test] - async fn staged_teardown_waits_for_in_flight_event_tasks() { - use std::sync::atomic::{AtomicBool, Ordering}; - - let event_tasks = TaskTracker::new(); - let finished = Arc::new(AtomicBool::new(false)); - let finished_flag = Arc::clone(&finished); - event_tasks.spawn(async move { - tokio::time::sleep(Duration::from_millis(50)).await; - finished_flag.store(true, Ordering::SeqCst); - }); - - let push_dispatcher = Arc::new(PushDispatcher::new(None, None)); - let relay_client = Arc::new( - RelayClient::new(Keys::generate(), test_relay_client_config()) - .await - .unwrap(), - ); - - let event_handle = tokio::spawn(async {}); - let health_handle = tokio::spawn(async {}); - let cleanup_handle = tokio::spawn(async {}); - let status_refresh_handle = tokio::spawn(async {}); - - tokio::time::timeout( - Duration::from_secs(5), - staged_teardown( - event_handle, - event_tasks.clone(), - push_dispatcher, - relay_client, - health_handle, - cleanup_handle, - status_refresh_handle, - ), - ) - .await - .expect("staged teardown must complete"); - - assert!( - finished.load(Ordering::SeqCst), - "in-flight event tasks must finish before teardown completes" - ); - assert!(event_tasks.is_closed()); - } - - #[tokio::test] - async fn staged_teardown_joins_the_event_loop_before_closing_the_tracker() { - use std::sync::atomic::{AtomicBool, Ordering}; - - // Model the shutdown race from the teardown-ordering bug: the event - // loop admits one final event right before it exits. Because teardown - // joins the loop before closing the tracker, that late task is still - // tracked, drained, and its dispatch window stays open. - let event_tasks = TaskTracker::new(); - let late_task_finished = Arc::new(AtomicBool::new(false)); - - let event_handle = { - let event_tasks = event_tasks.clone(); - let finished = Arc::clone(&late_task_finished); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(30)).await; - event_tasks.spawn(async move { - tokio::time::sleep(Duration::from_millis(30)).await; - finished.store(true, Ordering::SeqCst); - }); - }) - }; - - let push_dispatcher = Arc::new(PushDispatcher::new(None, None)); - let relay_client = Arc::new( - RelayClient::new(Keys::generate(), test_relay_client_config()) - .await - .unwrap(), - ); - let health_handle = tokio::spawn(async {}); - let cleanup_handle = tokio::spawn(async {}); - let status_refresh_handle = tokio::spawn(async {}); - - tokio::time::timeout( - Duration::from_secs(5), - staged_teardown( - event_handle, - event_tasks.clone(), - push_dispatcher, - relay_client, - health_handle, - cleanup_handle, - status_refresh_handle, - ), - ) - .await - .expect("staged teardown must complete"); - - assert!( - late_task_finished.load(Ordering::SeqCst), - "a task admitted right before the event loop exits must still be drained" - ); - } - - fn test_logging_config(level: &str) -> config::LoggingConfig { - config::LoggingConfig { - level: level.to_string(), - format: config::LogFormat::Json, - } - } - - #[test] - fn logging_filter_uses_configured_filter_without_env_filter() { - assert!( - logging_filter_from_env( - &test_logging_config("info"), - Err(std::env::VarError::NotPresent) - ) - .is_ok() - ); - } - - #[test] - fn logging_filter_rejects_invalid_configured_filter_without_panic() { - let config = test_logging_config("target=lvl"); - let result = std::panic::catch_unwind(|| { - logging_filter_from_env(&config, Err(std::env::VarError::NotPresent)) - }); - let error = match result.expect("invalid logging.level should not panic") { - Ok(_) => panic!("invalid logging.level should return an error"), - Err(error) => error, - }; - - assert!( - error - .to_string() - .contains("invalid logging.level filter: target=lvl") - ); - } - - #[test] - fn logging_filter_prefers_env_filter_over_invalid_configured_filter() { - assert!( - logging_filter_from_env(&test_logging_config("target=lvl"), Ok("warn".to_string())) - .is_ok() - ); - } - - #[test] - fn logging_filter_rejects_invalid_env_filter_without_using_config() { - let error = - logging_filter_from_env(&test_logging_config("info"), Ok("target=lvl".to_string())) - .expect_err("invalid RUST_LOG should return an error"); - - assert!( - error - .to_string() - .contains("invalid RUST_LOG filter: target=lvl") - ); - } - - #[tokio::test] - async fn healthcheck_command_accepts_success_status() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let app = Router::new().route("/health", get(|| async { StatusCode::OK })); - - let server = tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - let result = run_healthcheck(&format!("http://{addr}/health")).await; - - server.abort(); - assert!(result.is_ok()); - } - - #[tokio::test] - async fn healthcheck_command_rejects_error_status() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let app = Router::new().route( - "/health", - get(|| async { (StatusCode::SERVICE_UNAVAILABLE, "not ready") }), - ); - - let server = tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - let result = run_healthcheck(&format!("http://{addr}/health")).await; - - server.abort(); - assert!(result.is_err()); - } - - #[tokio::test] - async fn healthcheck_command_reports_unreachable_endpoint() { - let result = run_healthcheck("http://127.0.0.1:1/health").await; - - let error = result.expect_err("healthcheck should fail for unreachable endpoints"); - assert!( - error - .to_string() - .contains("Failed to reach health endpoint at http://127.0.0.1:1/health") - ); - } - - #[test] - fn resolve_server_private_key_prefers_inline_value() { - let mut config = config::ServerConfig { - private_key: Zeroizing::new("abc123".to_string()), - private_key_file: "/tmp/ignored".to_string(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 100_000, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, - max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, - max_notification_future_skew_secs: - crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; - - assert_eq!( - resolve_server_private_key(&mut config).unwrap().as_str(), - "abc123" - ); - assert_eq!(config.private_key.as_str(), ""); - } - - #[test] - fn resolve_server_private_key_reads_secret_file() { - let mut file = NamedTempFile::new().unwrap(); - writeln!(file, "abc123").unwrap(); - - let mut config = config::ServerConfig { - private_key: Zeroizing::new(String::new()), - private_key_file: file.path().display().to_string(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 100_000, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, - max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, - max_notification_future_skew_secs: - crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; - - assert_eq!( - resolve_server_private_key(&mut config).unwrap().as_str(), - "abc123" - ); - } - - #[test] - fn resolve_server_private_key_treats_whitespace_inline_value_as_empty() { - let mut file = NamedTempFile::new().unwrap(); - writeln!(file, "abc123").unwrap(); - - let mut config = config::ServerConfig { - private_key: Zeroizing::new(" \n\t ".to_string()), - private_key_file: format!(" {} ", file.path().display()), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 100_000, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, - max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, - max_notification_future_skew_secs: - crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; - - assert_eq!( - resolve_server_private_key(&mut config).unwrap().as_str(), - "abc123" - ); - } - - #[test] - fn resolve_server_private_key_returns_empty_when_unset() { - let mut config = config::ServerConfig { - private_key: Zeroizing::new(String::new()), - private_key_file: String::new(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 100_000, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, - max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, - max_notification_future_skew_secs: - crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; - - assert_eq!( - resolve_server_private_key(&mut config).unwrap().as_str(), - "" - ); - } - - #[test] - fn resolve_server_private_key_reports_missing_secret_file() { - let mut config = config::ServerConfig { - private_key: Zeroizing::new(String::new()), - private_key_file: "/tmp/definitely-missing-transponder-key".to_string(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 100_000, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, - max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, - max_notification_future_skew_secs: - crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; - - let error = resolve_server_private_key(&mut config) - .expect_err("missing secret file should return an error"); - assert!( - error - .to_string() - .contains("Failed to read server private key file") - ); - } - - #[test] - fn validate_startup_config_rejects_missing_private_key() { - let relays = config::RelayConfig { - clearnet: vec!["wss://relay.example.com".to_string()], - allow_unencrypted_clearnet_relays: false, - onion: Vec::new(), - reconnect_interval_secs: 5, - max_reconnect_attempts: 10, - connection_timeout_secs: 30, - }; - - let error = validate_startup_config("", &relays) - .expect_err("missing private key should be rejected"); - assert_eq!(error.to_string(), "Server private key is required"); - } - - #[test] - fn validate_startup_config_rejects_missing_relays() { - let relays = config::RelayConfig { - clearnet: Vec::new(), - allow_unencrypted_clearnet_relays: false, - onion: Vec::new(), - reconnect_interval_secs: 5, - max_reconnect_attempts: 10, - connection_timeout_secs: 30, - }; - - let error = validate_startup_config("abc123", &relays) - .expect_err("missing relays should be rejected"); - assert_eq!(error.to_string(), "At least one relay must be configured"); - } - - #[test] - #[cfg(not(feature = "tor"))] - fn validate_startup_config_rejects_onion_relays_without_tor_feature() { - let relays = config::RelayConfig { - clearnet: vec!["wss://relay.example.com".to_string()], - allow_unencrypted_clearnet_relays: false, - onion: vec!["wss://example.onion".to_string()], - reconnect_interval_secs: 5, - max_reconnect_attempts: 10, - connection_timeout_secs: 30, - }; - - let error = validate_startup_config("abc123", &relays) - .expect_err("onion relays should require the tor feature"); - assert_eq!(error.to_string(), "Onion relays require the 'tor' feature"); - } - - #[test] - #[cfg(feature = "tor")] - fn validate_startup_config_accepts_onion_only_relays() { - let relays = config::RelayConfig { - clearnet: Vec::new(), - allow_unencrypted_clearnet_relays: false, - onion: vec!["wss://example.onion".to_string()], - reconnect_interval_secs: 5, - max_reconnect_attempts: 10, - connection_timeout_secs: 30, - }; - - assert!(validate_startup_config("abc123", &relays).is_ok()); - } - - #[test] - fn parse_server_secret_key_accepts_nsec_format() { - let secret_key = Keys::generate().secret_key().to_bech32().unwrap(); - - let parsed_key = parse_server_secret_key(&secret_key).unwrap(); - - assert_eq!(parsed_key.to_bech32().unwrap(), secret_key); - } - - #[test] - fn parse_server_secret_key_accepts_resolved_file_value() { - let secret_key = Keys::generate().secret_key().to_bech32().unwrap(); - let mut file = NamedTempFile::new().unwrap(); - writeln!(file, " {secret_key} ").unwrap(); - - let mut config = config::ServerConfig { - private_key: Zeroizing::new(String::new()), - private_key_file: file.path().display().to_string(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 100_000, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, - max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, - max_notification_future_skew_secs: - crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; - - let resolved_key = resolve_server_private_key(&mut config).unwrap(); - let parsed_key = parse_server_secret_key(resolved_key.as_str()).unwrap(); - - assert_eq!(parsed_key.to_bech32().unwrap(), secret_key); - } - - // ---- #146: private key file permission check (unix) ---- - - #[cfg(unix)] - fn write_key_file_with_mode(mode: u32) -> NamedTempFile { - use std::os::unix::fs::PermissionsExt; - let file = NamedTempFile::new().unwrap(); - std::fs::write(file.path(), "abc123\n").unwrap(); - let mut perms = std::fs::metadata(file.path()).unwrap().permissions(); - perms.set_mode(mode); - std::fs::set_permissions(file.path(), perms).unwrap(); - file - } - - #[cfg(unix)] - #[test] - fn verify_private_key_file_permissions_accepts_0600() { - let file = write_key_file_with_mode(0o600); - assert!(verify_private_key_file_permissions(file.path()).is_ok()); - } - - #[cfg(unix)] - #[test] - fn verify_private_key_file_permissions_rejects_group_readable() { - let file = write_key_file_with_mode(0o640); - let error = verify_private_key_file_permissions(file.path()) - .expect_err("group-readable key file must be rejected"); - assert!(error.to_string().contains("group/other access"), "{error}"); - } - - #[cfg(unix)] - #[test] - fn verify_private_key_file_permissions_rejects_world_readable() { - let file = write_key_file_with_mode(0o644); - let error = verify_private_key_file_permissions(file.path()) - .expect_err("world-readable key file must be rejected"); - assert!(error.to_string().contains("group/other access"), "{error}"); - } - - #[cfg(unix)] - #[test] - fn resolve_server_private_key_rejects_permissive_key_file() { - // End-to-end: a permissive key file must fail the whole resolve path, - // not just the standalone permission check. - let file = write_key_file_with_mode(0o644); - let mut config = config::ServerConfig { - private_key: Zeroizing::new(String::new()), - private_key_file: file.path().display().to_string(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 100_000, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, - max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, - max_notification_future_skew_secs: - crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; - - let error = resolve_server_private_key(&mut config) - .expect_err("a group/world-readable key file must refuse to load"); - assert!(error.to_string().contains("group/other access"), "{error}"); - } - - #[cfg(unix)] - #[test] - fn resolve_server_private_key_reads_0600_key_file() { - let file = write_key_file_with_mode(0o600); - let mut config = config::ServerConfig { - private_key: Zeroizing::new(String::new()), - private_key_file: file.path().display().to_string(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 100_000, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, - max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, - max_notification_future_skew_secs: - crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; - - assert_eq!( - resolve_server_private_key(&mut config).unwrap().as_str(), - "abc123" - ); - } - - // ---- #150 / #142: init_logging accepts both enum variants ---- - - #[test] - fn init_logging_builds_layer_for_each_format() { - // `init_logging` installs a global subscriber (a process-wide, one-shot - // side effect), so exercise the format-selection logic through - // `logging_filter` + the format match instead of calling `init`. Both - // enum variants must yield a filter without error. - for format in [config::LogFormat::Json, config::LogFormat::Pretty] { - let config = config::LoggingConfig { - level: "info".to_string(), - format, - }; - assert!(logging_filter(&config).is_ok()); - } - } -} From 966b3fa34c2a67fc34687ffc2c3accf5cf866ceb Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:58:26 +0100 Subject: [PATCH 04/10] test: add shared ServerConfig fixtures for test harness Introduce test_support with default_server_config and server_config_with to replace repeated 15-field literals in app and processor tests. Co-authored-by: Cursor --- src/app.rs | 185 +++++----------------------------- src/lib.rs | 2 + src/nostr/events/processor.rs | 82 +++++---------- src/test_support.rs | 47 +++++++++ 4 files changed, 99 insertions(+), 217 deletions(-) create mode 100644 src/test_support.rs diff --git a/src/app.rs b/src/app.rs index 3f75d29..2a90e41 100644 --- a/src/app.rs +++ b/src/app.rs @@ -853,6 +853,7 @@ pub fn init_logging(config: &crate::config::LoggingConfig) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use crate::test_support::{default_server_config, server_config_with}; use axum::{Router, http::StatusCode, routing::get}; use std::io::Write; use std::path::PathBuf; @@ -1485,26 +1486,10 @@ mod tests { #[test] fn resolve_server_private_key_prefers_inline_value() { - let mut config = crate::config::ServerConfig { - private_key: Zeroizing::new("abc123".to_string()), - private_key_file: "/tmp/ignored".to_string(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 100_000, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, - max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, - max_notification_future_skew_secs: - crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; + let mut config = server_config_with(default_server_config(), |config| { + config.private_key = Zeroizing::new("abc123".to_string()); + config.private_key_file = "/tmp/ignored".to_string(); + }); assert_eq!( resolve_server_private_key(&mut config).unwrap().as_str(), @@ -1518,26 +1503,9 @@ mod tests { let mut file = NamedTempFile::new().unwrap(); writeln!(file, "abc123").unwrap(); - let mut config = crate::config::ServerConfig { - private_key: Zeroizing::new(String::new()), - private_key_file: file.path().display().to_string(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 100_000, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, - max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, - max_notification_future_skew_secs: - crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; + let mut config = server_config_with(default_server_config(), |config| { + config.private_key_file = file.path().display().to_string(); + }); assert_eq!( resolve_server_private_key(&mut config).unwrap().as_str(), @@ -1550,26 +1518,10 @@ mod tests { let mut file = NamedTempFile::new().unwrap(); writeln!(file, "abc123").unwrap(); - let mut config = crate::config::ServerConfig { - private_key: Zeroizing::new(" \n\t ".to_string()), - private_key_file: format!(" {} ", file.path().display()), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 100_000, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, - max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, - max_notification_future_skew_secs: - crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; + let mut config = server_config_with(default_server_config(), |config| { + config.private_key = Zeroizing::new(" \n\t ".to_string()); + config.private_key_file = format!(" {} ", file.path().display()); + }); assert_eq!( resolve_server_private_key(&mut config).unwrap().as_str(), @@ -1579,26 +1531,7 @@ mod tests { #[test] fn resolve_server_private_key_returns_empty_when_unset() { - let mut config = crate::config::ServerConfig { - private_key: Zeroizing::new(String::new()), - private_key_file: String::new(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 100_000, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, - max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, - max_notification_future_skew_secs: - crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; + let mut config = default_server_config(); assert_eq!( resolve_server_private_key(&mut config).unwrap().as_str(), @@ -1608,26 +1541,9 @@ mod tests { #[test] fn resolve_server_private_key_reports_missing_secret_file() { - let mut config = crate::config::ServerConfig { - private_key: Zeroizing::new(String::new()), - private_key_file: "/tmp/definitely-missing-transponder-key".to_string(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 100_000, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, - max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, - max_notification_future_skew_secs: - crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; + let mut config = server_config_with(default_server_config(), |config| { + config.private_key_file = "/tmp/definitely-missing-transponder-key".to_string(); + }); let error = resolve_server_private_key(&mut config) .expect_err("missing secret file should return an error"); @@ -1717,26 +1633,9 @@ mod tests { let mut file = NamedTempFile::new().unwrap(); writeln!(file, " {secret_key} ").unwrap(); - let mut config = crate::config::ServerConfig { - private_key: Zeroizing::new(String::new()), - private_key_file: file.path().display().to_string(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 100_000, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, - max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, - max_notification_future_skew_secs: - crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; + let mut config = server_config_with(default_server_config(), |config| { + config.private_key_file = file.path().display().to_string(); + }); let resolved_key = resolve_server_private_key(&mut config).unwrap(); let parsed_key = parse_server_secret_key(resolved_key.as_str()).unwrap(); @@ -1788,26 +1687,9 @@ mod tests { // End-to-end: a permissive key file must fail the whole resolve path, // not just the standalone permission check. let file = write_key_file_with_mode(0o644); - let mut config = crate::config::ServerConfig { - private_key: Zeroizing::new(String::new()), - private_key_file: file.path().display().to_string(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 100_000, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, - max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, - max_notification_future_skew_secs: - crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; + let mut config = server_config_with(default_server_config(), |config| { + config.private_key_file = file.path().display().to_string(); + }); let error = resolve_server_private_key(&mut config) .expect_err("a group/world-readable key file must refuse to load"); @@ -1818,26 +1700,9 @@ mod tests { #[test] fn resolve_server_private_key_reads_0600_key_file() { let file = write_key_file_with_mode(0o600); - let mut config = crate::config::ServerConfig { - private_key: Zeroizing::new(String::new()), - private_key_file: file.path().display().to_string(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 100_000, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: crate::nostr::events::DEFAULT_DEDUP_RETENTION_SECS, - max_notification_age_secs: crate::nostr::events::DEFAULT_MAX_NOTIFICATION_AGE_SECS, - max_notification_future_skew_secs: - crate::nostr::events::DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: crate::crypto::nip59::DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; + let mut config = server_config_with(default_server_config(), |config| { + config.private_key_file = file.path().display().to_string(); + }); assert_eq!( resolve_server_private_key(&mut config).unwrap().as_str(), diff --git a/src/lib.rs b/src/lib.rs index 8290f75..bc6b4ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,8 @@ pub mod telemetry; #[cfg(test)] pub(crate) mod test_metrics; #[cfg(test)] +pub(crate) mod test_support; +#[cfg(test)] pub(crate) mod test_vectors; pub use app::run; diff --git a/src/nostr/events/processor.rs b/src/nostr/events/processor.rs index 4fbb627..eebde7b 100644 --- a/src/nostr/events/processor.rs +++ b/src/nostr/events/processor.rs @@ -1003,30 +1003,23 @@ mod tests { counter_value, gauge_value as metric_gauge_value, histogram_sample_count as histogram_count, histogram_sample_sum, }; + use crate::test_support::{default_server_config, server_config_with}; use crate::test_vectors::scenarios; use zeroize::Zeroizing; #[test] fn token_rate_limit_config_from_server_config_matches_settings() { - let server = crate::config::ServerConfig { - private_key: Zeroizing::new(String::new()), - private_key_file: String::new(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 100_000, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: DEFAULT_DEDUP_RETENTION_SECS, - max_notification_age_secs: DEFAULT_MAX_NOTIFICATION_AGE_SECS, - max_notification_future_skew_secs: DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, - max_rate_limit_cache_size: 1234, - max_tokens_per_event: 25, - encrypted_token_rate_limit_per_minute: 111, - encrypted_token_rate_limit_per_hour: 2222, - device_token_rate_limit_per_minute: 333, - device_token_rate_limit_per_hour: 4444, - max_concurrent_event_processing: 7, - global_unwrap_rate_limit_per_minute: 555, - global_unwrap_rate_limit_per_hour: 6666, - }; + let server = server_config_with(default_server_config(), |server| { + server.max_rate_limit_cache_size = 1234; + server.max_tokens_per_event = 25; + server.encrypted_token_rate_limit_per_minute = 111; + server.encrypted_token_rate_limit_per_hour = 2222; + server.device_token_rate_limit_per_minute = 333; + server.device_token_rate_limit_per_hour = 4444; + server.max_concurrent_event_processing = 7; + server.global_unwrap_rate_limit_per_minute = 555; + server.global_unwrap_rate_limit_per_hour = 6666; + }); let rate_limit_config = TokenRateLimitConfig::from_server_config(&server); @@ -1043,25 +1036,13 @@ mod tests { #[test] fn replay_protection_config_from_server_config_matches_settings() { let state_path = PathBuf::from("/var/lib/transponder/dedup-events.log"); - let server = crate::config::ServerConfig { - private_key: Zeroizing::new(String::new()), - private_key_file: String::new(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 77, - dedup_state_path: state_path.clone(), - dedup_retention_secs: 88, - max_notification_age_secs: 99, - max_notification_future_skew_secs: 11, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; + let server = server_config_with(default_server_config(), |server| { + server.max_dedup_cache_size = 77; + server.dedup_state_path = state_path.clone(); + server.dedup_retention_secs = 88; + server.max_notification_age_secs = 99; + server.max_notification_future_skew_secs = 11; + }); let replay_config = ReplayProtectionConfig::from_server_config(&server); @@ -1077,25 +1058,12 @@ mod tests { #[test] fn replay_protection_config_from_server_config_disables_empty_state_path() { - let server = crate::config::ServerConfig { - private_key: Zeroizing::new(String::new()), - private_key_file: String::new(), - shutdown_timeout_secs: 10, - max_dedup_cache_size: 77, - dedup_state_path: PathBuf::new(), - dedup_retention_secs: 88, - max_notification_age_secs: 99, - max_notification_future_skew_secs: 11, - max_rate_limit_cache_size: 100_000, - max_tokens_per_event: DEFAULT_MAX_TOKENS_PER_EVENT, - encrypted_token_rate_limit_per_minute: 240, - encrypted_token_rate_limit_per_hour: 5000, - device_token_rate_limit_per_minute: 240, - device_token_rate_limit_per_hour: 5000, - max_concurrent_event_processing: 64, - global_unwrap_rate_limit_per_minute: 600, - global_unwrap_rate_limit_per_hour: 30_000, - }; + let server = server_config_with(default_server_config(), |server| { + server.max_dedup_cache_size = 77; + server.dedup_retention_secs = 88; + server.max_notification_age_secs = 99; + server.max_notification_future_skew_secs = 11; + }); let replay_config = ReplayProtectionConfig::from_server_config(&server); diff --git a/src/test_support.rs b/src/test_support.rs new file mode 100644 index 0000000..86129a1 --- /dev/null +++ b/src/test_support.rs @@ -0,0 +1,47 @@ +//! Shared test fixtures for constructing common configuration shapes. + +use std::path::PathBuf; + +use zeroize::Zeroizing; + +use crate::config::ServerConfig; +use crate::defaults::{ + DEFAULT_DEDUP_RETENTION_SECS, DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_HOUR, + DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_MINUTE, DEFAULT_MAX_DEDUP_CACHE_SIZE, + DEFAULT_MAX_NOTIFICATION_AGE_SECS, DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, DEFAULT_MAX_SIZE, + DEFAULT_MAX_TOKENS_PER_EVENT, DEFAULT_RATE_LIMIT_PER_HOUR, DEFAULT_RATE_LIMIT_PER_MINUTE, +}; + +/// Return a [`ServerConfig`] populated with production-default tuning values. +#[must_use] +pub fn default_server_config() -> ServerConfig { + ServerConfig { + private_key: Zeroizing::new(String::new()), + private_key_file: String::new(), + shutdown_timeout_secs: 10, + max_dedup_cache_size: DEFAULT_MAX_DEDUP_CACHE_SIZE, + dedup_state_path: PathBuf::new(), + dedup_retention_secs: DEFAULT_DEDUP_RETENTION_SECS, + max_notification_age_secs: DEFAULT_MAX_NOTIFICATION_AGE_SECS, + max_notification_future_skew_secs: DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, + max_rate_limit_cache_size: DEFAULT_MAX_SIZE, + max_tokens_per_event: DEFAULT_MAX_TOKENS_PER_EVENT, + encrypted_token_rate_limit_per_minute: DEFAULT_RATE_LIMIT_PER_MINUTE, + encrypted_token_rate_limit_per_hour: DEFAULT_RATE_LIMIT_PER_HOUR, + device_token_rate_limit_per_minute: DEFAULT_RATE_LIMIT_PER_MINUTE, + device_token_rate_limit_per_hour: DEFAULT_RATE_LIMIT_PER_HOUR, + max_concurrent_event_processing: crate::defaults::DEFAULT_MAX_CONCURRENT_EVENT_PROCESSING, + global_unwrap_rate_limit_per_minute: DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_MINUTE, + global_unwrap_rate_limit_per_hour: DEFAULT_GLOBAL_UNWRAP_LIMIT_PER_HOUR, + } +} + +/// Apply overrides to a default [`ServerConfig`]. +#[must_use] +pub fn server_config_with( + mut config: ServerConfig, + f: impl FnOnce(&mut ServerConfig), +) -> ServerConfig { + f(&mut config); + config +} From c73c3d83f28ce2936d901802cd03ebba560fde85 Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:07:22 +0100 Subject: [PATCH 05/10] refactor: use always-present Metrics with enabled no-op recording Pass Metrics through all components instead of Option, gating collection with an enabled flag so call sites no longer branch on optional handles. Co-authored-by: Cursor --- src/app.rs | 50 +++--- src/metrics.rs | 146 ++++++++++++++++ src/nostr/client.rs | 30 ++-- src/nostr/events/admission.rs | 13 +- src/nostr/events/processor.rs | 309 +++++++++++++++++----------------- src/push/apns.rs | 39 ++--- src/push/dispatcher.rs | 149 +++++++--------- src/push/fcm.rs | 41 +++-- src/push/retry.rs | 69 ++++---- src/server/health.rs | 47 +++--- 10 files changed, 490 insertions(+), 403 deletions(-) diff --git a/src/app.rs b/src/app.rs index 2a90e41..96ac545 100644 --- a/src/app.rs +++ b/src/app.rs @@ -37,10 +37,8 @@ fn classify_notification_receive_error(error: &RecvError) -> NotificationReceive } } -fn record_notification_receive_metrics(metrics: Option<&Metrics>, error: &RecvError) { - if let RecvError::Lagged(skipped) = error - && let Some(metrics) = metrics - { +fn record_notification_receive_metrics(metrics: &Metrics, error: &RecvError) { + if let RecvError::Lagged(skipped) = error { metrics.record_relay_notifications_lagged(); metrics.record_relay_notifications_dropped(*skipped); } @@ -221,7 +219,7 @@ async fn run_event_loop( event_semaphore: Arc, processor: Arc, event_tasks: TaskTracker, - metrics: Option, + metrics: Metrics, ) -> EventLoopExit { loop { tokio::select! { @@ -260,7 +258,7 @@ async fn run_event_loop( }); } Err(e) => { - record_notification_receive_metrics(metrics.as_ref(), &e); + record_notification_receive_metrics(&metrics, &e); match classify_notification_receive_error(&e) { NotificationReceiveAction::Continue => { @@ -337,22 +335,22 @@ pub async fn run(mut config: AppConfig) -> Result<()> { // `server::health::HealthServer::bind`). The #196 rider's structural fix // landed there, so no separate load-time warning is needed here. - // Initialize metrics - let metrics = if config.metrics.enabled { - match Metrics::new() { - Ok(m) => { - m.init_server_info(env!("CARGO_PKG_VERSION")); + // Initialize metrics (always present; recording gated by `enabled`). + let metrics = match Metrics::new() { + Ok(metrics) => { + let metrics = metrics.with_enabled(config.metrics.enabled); + if metrics.is_enabled() { + metrics.init_server_info(env!("CARGO_PKG_VERSION")); info!("Metrics initialized"); - Some(m) - } - Err(e) => { - error!(error = %e, "Failed to initialize metrics"); - None + } else { + info!("Metrics disabled"); } + metrics + } + Err(e) => { + error!(error = %e, "Failed to initialize metrics"); + Metrics::disabled() } - } else { - info!("Metrics disabled"); - None }; let server_private_key = resolve_server_private_key(&mut config.server)?; @@ -1022,7 +1020,7 @@ mod tests { fn notification_receive_lag_records_metrics() { let metrics = Metrics::new().unwrap(); - record_notification_receive_metrics(Some(&metrics), &RecvError::Lagged(3)); + record_notification_receive_metrics(&metrics, &RecvError::Lagged(3)); assert_eq!(metrics.relay_notifications_lagged_total.get(), 1); assert_eq!(metrics.relay_notifications_dropped_total.get(), 3); @@ -1032,7 +1030,7 @@ mod tests { fn notification_receive_close_does_not_record_metrics() { let metrics = Metrics::new().unwrap(); - record_notification_receive_metrics(Some(&metrics), &RecvError::Closed); + record_notification_receive_metrics(&metrics, &RecvError::Closed); assert_eq!(metrics.relay_notifications_lagged_total.get(), 0); assert_eq!(metrics.relay_notifications_dropped_total.get(), 0); @@ -1134,7 +1132,7 @@ mod tests { Arc::new(Semaphore::new(1)), test_event_processor(), TaskTracker::new(), - None, + Metrics::disabled(), ), ) .await @@ -1157,7 +1155,7 @@ mod tests { Arc::new(Semaphore::new(1)), test_event_processor(), TaskTracker::new(), - None, + Metrics::disabled(), ), ) .await @@ -1180,7 +1178,7 @@ mod tests { Arc::clone(&semaphore), Arc::clone(&processor), event_tasks.clone(), - None, + Metrics::disabled(), )); notification_tx.send(test_event_notification()).unwrap(); @@ -1219,7 +1217,7 @@ mod tests { Arc::new(Semaphore::new(1)), Arc::clone(&processor), event_tasks.clone(), - None, + Metrics::disabled(), )); notification_tx @@ -1261,7 +1259,7 @@ mod tests { Arc::new(Semaphore::new(1)), Arc::clone(&processor), event_tasks.clone(), - Some(metrics.clone()), + metrics.clone(), )); // The surviving (newest) event is still processed after the lag. diff --git a/src/metrics.rs b/src/metrics.rs index f030003..9e740d4 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -19,6 +19,9 @@ use prometheus::{ /// All metrics for the Transponder server. #[derive(Clone)] pub struct Metrics { + /// When false, recording methods are no-ops. + enabled: bool, + /// The Prometheus registry containing all metrics. pub registry: Registry, @@ -581,6 +584,7 @@ impl Metrics { registry.register(Box::new(server_info.clone()))?; Ok(Self { + enabled: true, registry, events_received_total, events_processed_total, @@ -628,8 +632,30 @@ impl Metrics { }) } + /// Return a copy with recording enabled or disabled. + #[must_use] + pub fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Whether metric recording is active. + #[must_use] + pub fn is_enabled(&self) -> bool { + self.enabled + } + + /// Return a metrics handle that never records (for tests and fallback init). + #[must_use] + pub fn disabled() -> Self { + Self::new().expect("metrics registry").with_enabled(false) + } + /// Initialize server startup metrics. pub fn init_server_info(&self, version: &str) { + if !self.enabled { + return; + } self.server_start_time_seconds.set( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -641,6 +667,9 @@ impl Metrics { /// Set the configured relay counts. pub fn set_relay_counts(&self, clearnet: usize, onion: usize) { + if !self.enabled { + return; + } self.relays_configured .with_label_values(&["clearnet"]) .set(clearnet as i64); @@ -651,47 +680,74 @@ impl Metrics { /// Record an event received from relays. pub fn record_event_received(&self) { + if !self.enabled { + return; + } self.events_received_total.inc(); } /// Record a successfully processed event. pub fn record_event_processed(&self) { + if !self.enabled { + return; + } self.events_processed_total.inc(); } /// Record a deduplicated event. pub fn record_event_deduplicated(&self) { + if !self.enabled { + return; + } self.events_deduplicated_total.inc(); } /// Record a failed event. pub fn record_event_failed(&self) { + if !self.enabled { + return; + } self.events_failed_total.inc(); } /// Record an event shed by global admission control before unwrap. pub fn record_event_shed(&self) { + if !self.enabled { + return; + } self.events_shed_total.inc(); } /// Record an event whose every token was shed by the per-token rate /// limiters, admitting zero notifications. pub fn record_event_rate_limited(&self) { + if !self.enabled { + return; + } self.events_rate_limited_total.inc(); } /// Increment the number of in-flight events. pub fn inc_events_in_flight(&self) { + if !self.enabled { + return; + } self.events_in_flight.inc(); } /// Decrement the number of in-flight events. pub fn dec_events_in_flight(&self) { + if !self.enabled { + return; + } self.events_in_flight.dec(); } /// Observe end-to-end event processing duration. pub fn observe_event_processing_duration(&self, outcome: EventOutcome, duration_secs: f64) { + if !self.enabled { + return; + } observe_label_value( &self.event_processing_duration_seconds, outcome.as_str(), @@ -701,6 +757,9 @@ impl Metrics { /// Observe gift-wrap unwrap duration. pub fn observe_gift_wrap_unwrap_duration(&self, outcome: OperationOutcome, duration_secs: f64) { + if !self.enabled { + return; + } observe_label_value( &self.gift_wrap_unwrap_duration_seconds, outcome.as_str(), @@ -723,21 +782,33 @@ impl Metrics { /// Observe encrypted token count per event. pub fn observe_tokens_per_event(&self, count: usize) { + if !self.enabled { + return; + } self.tokens_per_event.observe(count as f64); } /// Observe the size in bytes of the base64-decoded encrypted token blob. pub fn observe_notification_content_size_bytes(&self, size: usize) { + if !self.enabled { + return; + } self.notification_content_size_bytes.observe(size as f64); } /// Update dedup cache size. pub fn set_dedup_cache_size(&self, size: usize) { + if !self.enabled { + return; + } set_usize_gauge(&self.dedup_cache_size, size); } /// Record dedup cache evictions. pub fn record_dedup_evictions(&self, count: usize) { + if !self.enabled { + return; + } self.dedup_cache_evictions_total.inc_by(count as u64); } @@ -746,6 +817,9 @@ impl Metrics { /// `cache_type` should be "encrypted_token" or "device_token". /// `reason` should be "minute", "hour", or "capacity". pub fn record_rate_limited(&self, cache_type: &str, reason: Option<&str>) { + if !self.enabled { + return; + } self.tokens_rate_limited_total .with_label_values(&[cache_type, reason.unwrap_or("unknown")]) .inc(); @@ -755,6 +829,9 @@ impl Metrics { /// /// `cache_type` should be "encrypted_token" or "device_token". pub fn set_rate_limit_cache_size(&self, cache_type: &str, size: usize) { + if !self.enabled { + return; + } self.rate_limit_cache_size .with_label_values(&[cache_type]) .set(size as i64); @@ -764,6 +841,9 @@ impl Metrics { /// /// `cache_type` should be "encrypted_token" or "device_token". pub fn record_rate_limit_evictions(&self, cache_type: &str, count: usize) { + if !self.enabled { + return; + } self.rate_limit_evictions_total .with_label_values(&[cache_type]) .inc_by(count as u64); @@ -773,6 +853,9 @@ impl Metrics { /// /// `cache_type` should be "encrypted_token" or "device_token". pub fn record_rate_limit_admission_eviction(&self, cache_type: &str) { + if !self.enabled { + return; + } self.rate_limit_admission_evictions_total .with_label_values(&[cache_type]) .inc(); @@ -780,16 +863,25 @@ impl Metrics { /// Record a successful token decryption. pub fn record_token_decrypted(&self) { + if !self.enabled { + return; + } self.tokens_decrypted_total.inc(); } /// Record a failed token decryption. pub fn record_token_decryption_failed(&self) { + if !self.enabled { + return; + } self.tokens_decryption_failed_total.inc(); } /// Observe per-token decryption duration. pub fn observe_token_decrypt_duration(&self, outcome: OperationOutcome, duration_secs: f64) { + if !self.enabled { + return; + } observe_label_value( &self.token_decrypt_duration_seconds, outcome.as_str(), @@ -799,11 +891,17 @@ impl Metrics { /// Observe the number of notifications admitted to the push dispatcher per event. pub fn observe_notifications_admitted_per_event(&self, count: usize) { + if !self.enabled { + return; + } self.notifications_admitted_per_event.observe(count as f64); } /// Record a notification dispatched to push queue. pub fn record_push_dispatched(&self, platform: &str) { + if !self.enabled { + return; + } self.push_dispatched_total .with_label_values(&[platform]) .inc(); @@ -811,11 +909,17 @@ impl Metrics { /// Record a successful push notification. pub fn record_push_success(&self, platform: &str) { + if !self.enabled { + return; + } self.push_success_total.with_label_values(&[platform]).inc(); } /// Record a failed push notification. pub fn record_push_failed(&self, platform: &str, reason: &str) { + if !self.enabled { + return; + } self.push_failed_total .with_label_values(&[platform, reason]) .inc(); @@ -823,26 +927,41 @@ impl Metrics { /// Update the push queue size. pub fn set_push_queue_size(&self, size: usize) { + if !self.enabled { + return; + } set_usize_gauge(&self.push_queue_size, size); } /// Update the push queue capacity. pub fn set_push_queue_capacity(&self, size: usize) { + if !self.enabled { + return; + } set_usize_gauge(&self.push_queue_capacity, size); } /// Update available semaphore permits. pub fn set_push_semaphore_available(&self, available: usize) { + if !self.enabled { + return; + } set_usize_gauge(&self.push_semaphore_available, available); } /// Update the push concurrency limit. pub fn set_push_concurrency_limit(&self, limit: usize) { + if !self.enabled { + return; + } set_usize_gauge(&self.push_concurrency_limit, limit); } /// Record a push queue rejection. pub fn record_push_queue_rejected(&self, count: u64) { + if !self.enabled { + return; + } self.push_queue_rejected_total.inc_by(count); } @@ -861,11 +980,17 @@ impl Metrics { /// Record a push retry attempt. pub fn record_push_retry(&self, platform: &str) { + if !self.enabled { + return; + } self.push_retries_total.with_label_values(&[platform]).inc(); } /// Observe push request duration. pub fn observe_push_duration(&self, platform: &str, duration_secs: f64) { + if !self.enabled { + return; + } self.push_request_duration_seconds .with_label_values(&[platform]) .observe(duration_secs); @@ -873,6 +998,9 @@ impl Metrics { /// Record push response status. pub fn record_push_response_status(&self, platform: &str, status: u16) { + if !self.enabled { + return; + } self.push_response_status_total .with_label_values(&[platform, &status.to_string()]) .inc(); @@ -880,6 +1008,9 @@ impl Metrics { /// Record an auth token refresh. pub fn record_auth_token_refresh(&self, service: &str) { + if !self.enabled { + return; + } self.auth_token_refreshes_total .with_label_values(&[service]) .inc(); @@ -887,6 +1018,9 @@ impl Metrics { /// Update connected relay count. pub fn set_relays_connected(&self, relay_type: &str, count: usize) { + if !self.enabled { + return; + } self.relays_connected .with_label_values(&[relay_type]) .set(count as i64); @@ -894,16 +1028,25 @@ impl Metrics { /// Record a lagged relay notification event. pub fn record_relay_notifications_lagged(&self) { + if !self.enabled { + return; + } self.relay_notifications_lagged_total.inc(); } /// Record relay notifications dropped due to receiver lag. pub fn record_relay_notifications_dropped(&self, count: u64) { + if !self.enabled { + return; + } self.relay_notifications_dropped_total.inc_by(count); } /// Set the relay subscription lookback window. pub fn set_relay_subscription_lookback(&self, seconds: u64) { + if !self.enabled { + return; + } self.relay_subscription_lookback_seconds .set(i64::try_from(seconds).unwrap_or(i64::MAX)); } @@ -913,6 +1056,9 @@ impl Metrics { /// Publishing the inbox relay list is best-effort and non-fatal at startup; /// this counter lets operators alert on the routing breakage it implies. pub fn record_inbox_relay_publish_failed(&self) { + if !self.enabled { + return; + } self.inbox_relay_publish_failed_total.inc(); } diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 66370e9..d7ec820 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -181,22 +181,18 @@ pub struct RelayClient { /// in [`RelayClient::connect`]. Used by [`RelayClient::refresh_status`] to /// classify connected relays by origin list instead of URL shape. origins: Mutex>, - metrics: Option, + metrics: Metrics, } impl RelayClient { /// Create a new relay client with the given keys and configuration. #[allow(dead_code)] pub async fn new(keys: Keys, config: RelayConfig) -> Result { - Self::with_metrics(keys, config, None).await + Self::with_metrics(keys, config, Metrics::disabled()).await } /// Create a new relay client with metrics. - pub async fn with_metrics( - keys: Keys, - config: RelayConfig, - metrics: Option, - ) -> Result { + pub async fn with_metrics(keys: Keys, config: RelayConfig, metrics: Metrics) -> Result { validate_relay_config(&config)?; let reconnect_attempt_limiter = ReconnectAttemptLimiter::new(config.max_reconnect_attempts); @@ -211,10 +207,8 @@ impl RelayClient { let total = config.clearnet.len() + config.onion.len(); - if let Some(metrics) = &metrics { - metrics.set_relay_counts(config.clearnet.len(), config.onion.len()); - metrics.set_relay_subscription_lookback(NIP59_TIMESTAMP_TWEAK_WINDOW_SECS); - } + metrics.set_relay_counts(config.clearnet.len(), config.onion.len()); + metrics.set_relay_subscription_lookback(NIP59_TIMESTAMP_TWEAK_WINDOW_SECS); Ok(Self { client, @@ -383,10 +377,8 @@ impl RelayClient { } } - if let Some(metrics) = &self.metrics { - metrics.set_relays_connected("clearnet", clearnet); - metrics.set_relays_connected("onion", tor); - } + self.metrics.set_relays_connected("clearnet", clearnet); + self.metrics.set_relays_connected("onion", tor); let mut status = self.status.write().await; status.clearnet_connected = clearnet; @@ -507,9 +499,7 @@ impl RelayClient { } fn record_inbox_relay_publish_failed(&self) { - if let Some(metrics) = &self.metrics { - metrics.record_inbox_relay_publish_failed(); - } + self.metrics.record_inbox_relay_publish_failed(); } /// The configured relay URLs to advertise, as raw config strings. @@ -870,7 +860,7 @@ mod tests { let config = test_relay_config(vec![relay_url.to_string()]); let metrics = Metrics::new().unwrap(); - let client = RelayClient::with_metrics(keys, config, Some(metrics.clone())) + let client = RelayClient::with_metrics(keys, config, metrics.clone()) .await .unwrap(); client.client.add_relay(&relay_url).await.unwrap(); @@ -1455,7 +1445,7 @@ mod tests { let config = test_relay_config(vec![relay_url.to_string()]); let metrics = Metrics::new().unwrap(); - let client = RelayClient::with_metrics(keys, config, Some(metrics.clone())) + let client = RelayClient::with_metrics(keys, config, metrics.clone()) .await .unwrap(); client.client.add_relay(&relay_url).await.unwrap(); diff --git a/src/nostr/events/admission.rs b/src/nostr/events/admission.rs index 95fe287..4e8b988 100644 --- a/src/nostr/events/admission.rs +++ b/src/nostr/events/admission.rs @@ -8,24 +8,19 @@ use crate::rate_limiter::{RateLimitReservation, RateLimiter}; #[must_use] pub(crate) struct InFlightEventGuard<'a> { - metrics: Option<&'a Metrics>, + metrics: &'a Metrics, } impl<'a> InFlightEventGuard<'a> { - pub(crate) fn new(metrics: Option<&'a Metrics>) -> Self { - if let Some(m) = metrics { - m.inc_events_in_flight(); - } - + pub(crate) fn new(metrics: &'a Metrics) -> Self { + metrics.inc_events_in_flight(); Self { metrics } } } impl Drop for InFlightEventGuard<'_> { fn drop(&mut self) { - if let Some(m) = self.metrics { - m.dec_events_in_flight(); - } + self.metrics.dec_events_in_flight(); } } diff --git a/src/nostr/events/processor.rs b/src/nostr/events/processor.rs index eebde7b..29726d8 100644 --- a/src/nostr/events/processor.rs +++ b/src/nostr/events/processor.rs @@ -58,7 +58,7 @@ pub struct EventProcessor { device_token_limiter: RateLimiter<[u8; 32]>, /// Maximum encrypted tokens accepted in a single event. max_tokens_per_event: usize, - metrics: Option, + metrics: Metrics, } /// Configuration for token rate limiting. @@ -212,7 +212,7 @@ impl EventProcessor { push_dispatcher, DEFAULT_MAX_DEDUP_CACHE_SIZE, TokenRateLimitConfig::default(), - None, + Metrics::disabled(), ) } @@ -230,7 +230,7 @@ impl EventProcessor { push_dispatcher, max_cache_size, TokenRateLimitConfig::default(), - None, + Metrics::disabled(), ) } @@ -242,7 +242,7 @@ impl EventProcessor { push_dispatcher: Arc, max_dedup_cache_size: usize, rate_limit_config: TokenRateLimitConfig, - metrics: Option, + metrics: Metrics, ) -> Self { Self::with_replay_config( nip59_handler, @@ -266,7 +266,7 @@ impl EventProcessor { push_dispatcher: Arc, rate_limit_config: TokenRateLimitConfig, replay_config: ReplayProtectionConfig, - metrics: Option, + metrics: Metrics, ) -> Result { // `max_dedup_cache_size` is `NonZeroUsize`, so the previous silent // zero-to-default substitution is unrepresentable. @@ -324,13 +324,11 @@ impl EventProcessor { /// failed and the failure was logged/recorded. Processing failures are not /// propagated so the event loop can continue with later events. pub async fn process(&self, event: &Event) -> Result { - let _in_flight = InFlightEventGuard::new(self.metrics.as_ref()); + let _in_flight = InFlightEventGuard::new(&self.metrics); let started_at = StageTimer::start(); // Record event received - if let Some(ref m) = self.metrics { - m.record_event_received(); - } + self.metrics.record_event_received(); // Logged at trace, not info: the kind:1059 event ID is a stable, // public correlation handle. Emitting it per-event at info would @@ -350,13 +348,12 @@ impl EventProcessor { // prior "not marked seen on transient failure" semantics. if !self.try_reserve(event.id).await { trace!("Skipping duplicate event"); - if let Some(ref m) = self.metrics { - m.record_event_deduplicated(); - m.observe_event_processing_duration( - EventOutcome::Duplicate, - started_at.elapsed_secs(), - ); - } + self.metrics.record_event_deduplicated(); + self.metrics.observe_event_processing_duration( + EventOutcome::Duplicate, + started_at.elapsed_secs(), + ); + return Ok(false); } @@ -379,10 +376,10 @@ impl EventProcessor { reason = admission.limit_reason(), "Shed event before unwrap (global admission control)" ); - if let Some(ref m) = self.metrics { - m.record_event_shed(); - m.observe_event_processing_duration(EventOutcome::Shed, started_at.elapsed_secs()); - } + self.metrics.record_event_shed(); + self.metrics + .observe_event_processing_duration(EventOutcome::Shed, started_at.elapsed_secs()); + return Ok(false); } @@ -401,13 +398,12 @@ impl EventProcessor { // Prometheus via `observe_notifications_admitted_per_event`. trace!("Processed notification event"); - if let Some(ref m) = self.metrics { - m.record_event_processed(); - m.observe_event_processing_duration( - EventOutcome::Processed, - started_at.elapsed_secs(), - ); - } + self.metrics.record_event_processed(); + self.metrics.observe_event_processing_duration( + EventOutcome::Processed, + started_at.elapsed_secs(), + ); + Ok(true) } Ok(ProcessOutcome::RateLimitedShed) => { @@ -418,13 +414,12 @@ impl EventProcessor { // duplicate, and do NOT count it as processed. self.release_reservation(&event.id).await; trace!("Shed event (all tokens per-token rate limited)"); - if let Some(ref m) = self.metrics { - m.record_event_rate_limited(); - m.observe_event_processing_duration( - EventOutcome::RateLimited, - started_at.elapsed_secs(), - ); - } + self.metrics.record_event_rate_limited(); + self.metrics.observe_event_processing_duration( + EventOutcome::RateLimited, + started_at.elapsed_secs(), + ); + Ok(false) } Err(e) => { @@ -440,13 +435,12 @@ impl EventProcessor { // Log but don't propagate - we want to continue processing other events warn!(error = %e, "Failed to process event"); - if let Some(ref m) = self.metrics { - m.record_event_failed(); - m.observe_event_processing_duration( - EventOutcome::Failed, - started_at.elapsed_secs(), - ); - } + self.metrics.record_event_failed(); + self.metrics.observe_event_processing_duration( + EventOutcome::Failed, + started_at.elapsed_secs(), + ); + Ok(false) } } @@ -498,21 +492,19 @@ impl EventProcessor { let unwrap_started_at = StageTimer::start(); let notification = match self.nip59_handler.unwrap(event).await { Ok(notification) => { - if let Some(ref m) = self.metrics { - m.observe_gift_wrap_unwrap_duration( - OperationOutcome::Success, - unwrap_started_at.elapsed_secs(), - ); - } + self.metrics.observe_gift_wrap_unwrap_duration( + OperationOutcome::Success, + unwrap_started_at.elapsed_secs(), + ); + notification } Err(e) => { - if let Some(ref m) = self.metrics { - m.observe_gift_wrap_unwrap_duration( - OperationOutcome::Failed, - unwrap_started_at.elapsed_secs(), - ); - } + self.metrics.observe_gift_wrap_unwrap_duration( + OperationOutcome::Failed, + unwrap_started_at.elapsed_secs(), + ); + return Err(e); } }; @@ -525,23 +517,22 @@ impl EventProcessor { let parse_started_at = StageTimer::start(); let token_bytes = match notification.parse_tokens_with_limit(self.max_tokens_per_event) { Ok(token_bytes) => { - if let Some(ref m) = self.metrics { - m.observe_notification_parse_duration( - OperationOutcome::Success, - parse_started_at.elapsed_secs(), - ); - m.observe_tokens_per_event(token_bytes.len()); - m.observe_notification_content_size_bytes(notification.content.len()); - } + self.metrics.observe_notification_parse_duration( + OperationOutcome::Success, + parse_started_at.elapsed_secs(), + ); + self.metrics.observe_tokens_per_event(token_bytes.len()); + self.metrics + .observe_notification_content_size_bytes(notification.content.len()); + token_bytes } Err(e) => { - if let Some(ref m) = self.metrics { - m.observe_notification_parse_duration( - OperationOutcome::Failed, - parse_started_at.elapsed_secs(), - ); - } + self.metrics.observe_notification_parse_duration( + OperationOutcome::Failed, + parse_started_at.elapsed_secs(), + ); + return Err(e); } }; @@ -574,10 +565,9 @@ impl EventProcessor { .encrypted_token_limiter .check_and_increment(&encrypted_key) .await; - if encrypted_result.admission_evicted() - && let Some(ref m) = self.metrics - { - m.record_rate_limit_admission_eviction("encrypted_token"); + if encrypted_result.admission_evicted() { + self.metrics + .record_rate_limit_admission_eviction("encrypted_token"); } self.publish_rate_limit_gauge("encrypted_token", encrypted_result.sampled_cache_len()); if !encrypted_result.is_allowed() { @@ -586,9 +576,9 @@ impl EventProcessor { reason = encrypted_result.limit_reason(), "Rate limited encrypted token" ); - if let Some(ref m) = self.metrics { - m.record_rate_limited("encrypted_token", encrypted_result.limit_reason()); - } + self.metrics + .record_rate_limited("encrypted_token", encrypted_result.limit_reason()); + continue; } let encrypted_reservation = encrypted_result @@ -609,13 +599,12 @@ impl EventProcessor { let decrypt_started_at = StageTimer::start(); let payload = match self.token_decryptor.decrypt_bytes(&bytes) { Ok(p) => { - if let Some(ref m) = self.metrics { - m.record_token_decrypted(); - m.observe_token_decrypt_duration( - OperationOutcome::Success, - decrypt_started_at.elapsed_secs(), - ); - } + self.metrics.record_token_decrypted(); + self.metrics.observe_token_decrypt_duration( + OperationOutcome::Success, + decrypt_started_at.elapsed_secs(), + ); + p } Err(e) => { @@ -627,13 +616,12 @@ impl EventProcessor { guard.keep_charge(); terminally_dropped_any = true; trace!(error = %e, "Failed to decrypt token (ignoring)"); - if let Some(ref m) = self.metrics { - m.record_token_decryption_failed(); - m.observe_token_decrypt_duration( - OperationOutcome::Failed, - decrypt_started_at.elapsed_secs(), - ); - } + self.metrics.record_token_decryption_failed(); + self.metrics.observe_token_decrypt_duration( + OperationOutcome::Failed, + decrypt_started_at.elapsed_secs(), + ); + continue; } }; @@ -650,9 +638,8 @@ impl EventProcessor { terminally_dropped_any = true; let platform = platform_metric_label(payload.platform); trace!(platform, "Dropping token: platform not configured"); - if let Some(ref m) = self.metrics { - m.record_push_failed(platform, "unconfigured"); - } + self.metrics.record_push_failed(platform, "unconfigured"); + continue; } if !PushDispatcher::token_is_encodable(&payload) { @@ -660,9 +647,9 @@ impl EventProcessor { terminally_dropped_any = true; let platform = platform_metric_label(payload.platform); trace!(platform, "Dropping token: device token not encodable"); - if let Some(ref m) = self.metrics { - m.record_push_failed(platform, "invalid_encoding"); - } + self.metrics + .record_push_failed(platform, "invalid_encoding"); + continue; } @@ -672,10 +659,9 @@ impl EventProcessor { .device_token_limiter .check_and_increment(&device_key) .await; - if device_result.admission_evicted() - && let Some(ref m) = self.metrics - { - m.record_rate_limit_admission_eviction("device_token"); + if device_result.admission_evicted() { + self.metrics + .record_rate_limit_admission_eviction("device_token"); } self.publish_rate_limit_gauge("device_token", device_result.sampled_cache_len()); if !device_result.is_allowed() { @@ -690,9 +676,9 @@ impl EventProcessor { reason = device_result.limit_reason(), "Rate limited device token" ); - if let Some(ref m) = self.metrics { - m.record_rate_limited("device_token", device_result.limit_reason()); - } + self.metrics + .record_rate_limited("device_token", device_result.limit_reason()); + continue; } let device_reservation = device_result @@ -707,9 +693,8 @@ impl EventProcessor { } if payloads.is_empty() { - if let Some(ref m) = self.metrics { - m.observe_notifications_admitted_per_event(0); - } + self.metrics.observe_notifications_admitted_per_event(0); + // Distinguish a pure per-token rate-limit shed (retryable) from an // event that genuinely carried no dispatchable token (terminal). The // shed path only applies when at least one token was rate-limited and @@ -724,13 +709,12 @@ impl EventProcessor { let dispatch_started_at = StageTimer::start(); match self.push_dispatcher.dispatch(payloads).await { Ok(count) => { - if let Some(ref m) = self.metrics { - m.observe_push_dispatch_admission_duration( - OperationOutcome::Success, - dispatch_started_at.elapsed_secs(), - ); - m.observe_notifications_admitted_per_event(count); - } + self.metrics.observe_push_dispatch_admission_duration( + OperationOutcome::Success, + dispatch_started_at.elapsed_secs(), + ); + self.metrics.observe_notifications_admitted_per_event(count); + Ok(ProcessOutcome::Admitted) } Err(e) => { @@ -743,13 +727,12 @@ impl EventProcessor { .await; } - if let Some(ref m) = self.metrics { - m.observe_push_dispatch_admission_duration( - OperationOutcome::Failed, - dispatch_started_at.elapsed_secs(), - ); - m.observe_notifications_admitted_per_event(0); - } + self.metrics.observe_push_dispatch_admission_duration( + OperationOutcome::Failed, + dispatch_started_at.elapsed_secs(), + ); + self.metrics.observe_notifications_admitted_per_event(0); + Err(e) } } @@ -764,8 +747,8 @@ impl EventProcessor { /// cleanup tick — while keeping the metric's existing per-`cache_type` /// label shape and paying a gauge write only on sampled calls. fn publish_rate_limit_gauge(&self, cache_type: &str, sampled_len: Option) { - if let (Some(len), Some(m)) = (sampled_len, self.metrics.as_ref()) { - m.set_rate_limit_cache_size(cache_type, len); + if let Some(len) = sampled_len { + self.metrics.set_rate_limit_cache_size(cache_type, len); } } @@ -816,9 +799,8 @@ impl EventProcessor { seen.put(event_id, SeenEvent::reservation(Instant::now())); // Update cache size metric - if let Some(ref m) = self.metrics { - m.set_dedup_cache_size(seen.len()); - } + self.metrics.set_dedup_cache_size(seen.len()); + true } @@ -834,9 +816,7 @@ impl EventProcessor { seen.pop(event_id); // Update cache size metric - if let Some(ref m) = self.metrics { - m.set_dedup_cache_size(seen.len()); - } + self.metrics.set_dedup_cache_size(seen.len()); } /// Refresh the seen timestamp for an already-reserved event. @@ -857,8 +837,8 @@ impl EventProcessor { // touched on the rare path where the entry was evicted between // reservation and completion and had to be re-inserted. let size_changed = seen.mark_terminal(event_id, now); - if size_changed && let Some(ref m) = self.metrics { - m.set_dedup_cache_size(seen.len()); + if size_changed { + self.metrics.set_dedup_cache_size(seen.len()); } } @@ -924,11 +904,9 @@ impl EventProcessor { } } - if let Some(ref m) = self.metrics { - m.set_dedup_cache_size(remaining); - if evicted > 0 { - m.record_dedup_evictions(evicted); - } + self.metrics.set_dedup_cache_size(remaining); + if evicted > 0 { + self.metrics.record_dedup_evictions(evicted); } if evicted > 0 { debug!( @@ -945,11 +923,11 @@ impl EventProcessor { // Clean encrypted token rate limiter cache let encrypted_stats = self.encrypted_token_limiter.cleanup().await; - if let Some(ref m) = self.metrics { - m.set_rate_limit_cache_size("encrypted_token", encrypted_stats.remaining); - if encrypted_stats.evicted > 0 { - m.record_rate_limit_evictions("encrypted_token", encrypted_stats.evicted); - } + self.metrics + .set_rate_limit_cache_size("encrypted_token", encrypted_stats.remaining); + if encrypted_stats.evicted > 0 { + self.metrics + .record_rate_limit_evictions("encrypted_token", encrypted_stats.evicted); } if encrypted_stats.evicted > 0 { debug!( @@ -961,11 +939,11 @@ impl EventProcessor { // Clean device token rate limiter cache let device_stats = self.device_token_limiter.cleanup().await; - if let Some(ref m) = self.metrics { - m.set_rate_limit_cache_size("device_token", device_stats.remaining); - if device_stats.evicted > 0 { - m.record_rate_limit_evictions("device_token", device_stats.evicted); - } + self.metrics + .set_rate_limit_cache_size("device_token", device_stats.remaining); + if device_stats.evicted > 0 { + self.metrics + .record_rate_limit_evictions("device_token", device_stats.evicted); } if device_stats.evicted > 0 { debug!( @@ -1080,7 +1058,11 @@ mod tests { secp256k1::SecretKey::from_slice(&server_keys.secret_key().to_secret_bytes()) .expect("valid secret key"); let token_decryptor = TokenDecryptor::new(&mut secp_secret_key); - let push_dispatcher = Arc::new(PushDispatcher::new(None, None)); + let push_dispatcher = Arc::new(PushDispatcher::with_metrics( + None, + None, + Metrics::disabled(), + )); EventProcessor::new(nip59_handler, token_decryptor, push_dispatcher) } @@ -1090,7 +1072,11 @@ mod tests { secp256k1::SecretKey::from_slice(&server_keys.secret_key().to_secret_bytes()) .expect("valid secret key"); let token_decryptor = TokenDecryptor::new(&mut secp_secret_key); - let push_dispatcher = Arc::new(PushDispatcher::new(None, None)); + let push_dispatcher = Arc::new(PushDispatcher::with_metrics( + None, + None, + Metrics::disabled(), + )); EventProcessor::with_cache_size(nip59_handler, token_decryptor, push_dispatcher, cache_size) } @@ -1103,14 +1089,18 @@ mod tests { secp256k1::SecretKey::from_slice(&server_keys.secret_key().to_secret_bytes()) .expect("valid secret key"); let token_decryptor = TokenDecryptor::new(&mut secp_secret_key); - let push_dispatcher = Arc::new(PushDispatcher::new(None, None)); + let push_dispatcher = Arc::new(PushDispatcher::with_metrics( + None, + None, + Metrics::disabled(), + )); EventProcessor::with_replay_config( nip59_handler, token_decryptor, push_dispatcher, TokenRateLimitConfig::default(), replay_config, - None, + Metrics::disabled(), ) .expect("replay-protected processor") } @@ -1141,7 +1131,7 @@ mod tests { let push_dispatcher = Arc::new(PushDispatcher::with_metrics( Some(ApnsClient::mock(apns_config, true)), None, - Some(metrics.clone()), + metrics.clone(), )); ( EventProcessor::with_full_config( @@ -1150,7 +1140,7 @@ mod tests { push_dispatcher, DEFAULT_MAX_DEDUP_CACHE_SIZE, rate_limit_config, - Some(metrics.clone()), + metrics.clone(), ), metrics, ) @@ -1188,7 +1178,7 @@ mod tests { let push_dispatcher = Arc::new(PushDispatcher::with_metrics( Some(ApnsClient::mock(apns_config, true)), None, - Some(metrics.clone()), + metrics.clone(), )); push_dispatcher.wait_for_completion().await; @@ -1199,7 +1189,7 @@ mod tests { push_dispatcher, DEFAULT_MAX_DEDUP_CACHE_SIZE, rate_limit_config, - Some(metrics.clone()), + metrics.clone(), ), metrics, ) @@ -2393,9 +2383,10 @@ mod tests { bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), }; - let push_dispatcher = Arc::new(PushDispatcher::new( + let push_dispatcher = Arc::new(PushDispatcher::with_metrics( Some(ApnsClient::mock(apns_config, true)), None, + Metrics::disabled(), )); EventProcessor::with_full_config( nip59_handler, @@ -2403,7 +2394,7 @@ mod tests { push_dispatcher, DEFAULT_MAX_DEDUP_CACHE_SIZE, rate_limit_config, - None, + Metrics::disabled(), ) } @@ -2702,7 +2693,7 @@ mod tests { let push_dispatcher = Arc::new(PushDispatcher::with_metrics( None, Some(FcmClient::mock(fcm_config, true)), - Some(metrics.clone()), + metrics.clone(), )); let processor = EventProcessor::with_full_config( nip59_handler, @@ -2710,7 +2701,7 @@ mod tests { push_dispatcher, DEFAULT_MAX_DEDUP_CACHE_SIZE, TokenRateLimitConfig::default(), - Some(metrics.clone()), + metrics.clone(), ); let encryptor = TokenEncryptor::from_keys(&server_keys); @@ -2785,7 +2776,7 @@ mod tests { let push_dispatcher = Arc::new(PushDispatcher::with_metrics( None, Some(FcmClient::mock(fcm_config, true)), - Some(metrics.clone()), + metrics.clone(), )); // Retain a handle to inspect DeliveryHealth after processing. let dispatcher_handle = Arc::clone(&push_dispatcher); @@ -2795,7 +2786,7 @@ mod tests { push_dispatcher, DEFAULT_MAX_DEDUP_CACHE_SIZE, TokenRateLimitConfig::default(), - Some(metrics.clone()), + metrics.clone(), ); // Seed a real hard-failure streak on FCM just below the flagging @@ -2881,7 +2872,7 @@ mod tests { let push_dispatcher = Arc::new(PushDispatcher::with_metrics( None, Some(FcmClient::mock(fcm_config, true)), - Some(metrics.clone()), + metrics.clone(), )); let processor = EventProcessor::with_full_config( nip59_handler, @@ -2889,7 +2880,7 @@ mod tests { push_dispatcher, DEFAULT_MAX_DEDUP_CACHE_SIZE, TokenRateLimitConfig::default(), - Some(metrics.clone()), + metrics.clone(), ); // Build an FCM token whose device bytes are not valid UTF-8. diff --git a/src/push/apns.rs b/src/push/apns.rs index 63fe99b..4082e1f 100644 --- a/src/push/apns.rs +++ b/src/push/apns.rs @@ -202,7 +202,7 @@ pub(crate) struct ApnsTokenGenerator { encoding_key: Option, team_id: String, key_id: String, - metrics: Option, + metrics: Metrics, } impl ApnsTokenGenerator { @@ -244,9 +244,7 @@ impl AuthTokenGenerator for ApnsTokenGenerator { .map_err(|e| TokenAcquisitionError::permanent(Error::from(e)))?, ); - if let Some(metrics) = &self.metrics { - metrics.record_auth_token_refresh("apns_jwt"); - } + self.metrics.record_auth_token_refresh("apns_jwt"); trace!("Generated new APNs JWT token"); Ok(MintedToken { @@ -261,7 +259,7 @@ pub struct ApnsClient { pub(crate) http_client: Client, pub(crate) config: ApnsConfig, pub(crate) token_cache: TokenCache, - pub(crate) metrics: Option, + pub(crate) metrics: Metrics, /// Test-only override for the APNs base URL (scheme + host + port). #[cfg(test)] pub(crate) test_base_url: Option, @@ -280,11 +278,11 @@ impl ApnsClient { /// Create a new APNs client. #[allow(dead_code)] pub async fn new(config: ApnsConfig) -> Result { - Self::with_metrics(config, None).await + Self::with_metrics(config, Metrics::disabled()).await } /// Create a new APNs client with metrics. - pub async fn with_metrics(config: ApnsConfig, metrics: Option) -> Result { + pub async fn with_metrics(config: ApnsConfig, metrics: Metrics) -> Result { let http_client = Client::builder() .http2_prior_knowledge() .timeout(Duration::from_secs(30)) @@ -384,12 +382,12 @@ impl ApnsClient { "APNs", || self.send_once(url.as_str(), &request_parts), backoff_permit, - self.metrics.as_ref(), + self.metrics.clone(), ) .await; - if let Some(metrics) = &self.metrics { - metrics.observe_push_duration("apns", start.elapsed().as_secs_f64()); - } + self.metrics + .observe_push_duration("apns", start.elapsed().as_secs_f64()); + result } @@ -423,9 +421,8 @@ impl ApnsClient { ) -> SendAttemptResult { let status = response.status(); - if let Some(metrics) = &self.metrics { - metrics.record_push_response_status("apns", status.as_u16()); - } + self.metrics + .record_push_response_status("apns", status.as_u16()); match status.as_u16() { 200 => { @@ -603,7 +600,7 @@ impl ApnsClient { // error can reach any log sink downstream (issue #172). .map_err(|e| Error::from(e).redact_transport_url()) }, - self.metrics.as_ref(), + &self.metrics, ) .await { @@ -637,13 +634,13 @@ impl ApnsClient { encoding_key, team_id: config.team_id.clone(), key_id: config.key_id.clone(), - metrics: None, + metrics: Metrics::disabled(), }); Self { http_client: Client::new(), config, token_cache, - metrics: None, + metrics: Metrics::disabled(), test_base_url: None, } } @@ -1019,7 +1016,7 @@ mod tests { let metrics = Metrics::new().unwrap(); let mut client = ApnsClient::mock(test_config(), false); - client.metrics = Some(metrics.clone()); + client.metrics = metrics.clone(); let response = Client::new() .post(format!("{}/3/device/{}", mock_server.uri(), "deadbeef1234")) @@ -1071,7 +1068,7 @@ mod tests { let metrics = Metrics::new().unwrap(); let mut client = ApnsClient::mock(test_config(), true); - client.metrics = Some(metrics.clone()); + client.metrics = metrics.clone(); client.test_base_url = Some(mock_server.uri()); client .seed_token("cached", SystemTime::now() + Duration::from_secs(3600)) @@ -1924,8 +1921,8 @@ OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r payload_mode: Default::default(), }; - let metrics = Metrics::default(); - let client = ApnsClient::with_metrics(config, Some(metrics.clone())) + let metrics = Metrics::new().unwrap(); + let client = ApnsClient::with_metrics(config, metrics.clone()) .await .unwrap(); diff --git a/src/push/dispatcher.rs b/src/push/dispatcher.rs index cb43293..fec2390 100644 --- a/src/push/dispatcher.rs +++ b/src/push/dispatcher.rs @@ -242,7 +242,7 @@ struct DispatchWorkerContext { inflight: Arc, /// Passive per-provider delivery-health signal (see [`DeliveryHealth`]). delivery_health: Arc, - metrics: Option, + metrics: Metrics, } /// Push notification dispatcher. @@ -265,7 +265,7 @@ pub struct PushDispatcher { dispatcher_handle: tokio::sync::Mutex>>, inflight: Arc, delivery_health: Arc, - metrics: Option, + metrics: Metrics, } impl PushDispatcher { @@ -274,7 +274,7 @@ impl PushDispatcher { /// This spawns a dispatcher task that processes the bounded queue of notifications. #[allow(dead_code)] pub fn new(apns_client: Option, fcm_client: Option) -> Self { - Self::with_metrics(apns_client, fcm_client, None) + Self::with_metrics(apns_client, fcm_client, Metrics::disabled()) } /// Create a new push dispatcher with metrics. @@ -283,7 +283,7 @@ impl PushDispatcher { pub fn with_metrics( apns_client: Option, fcm_client: Option, - metrics: Option, + metrics: Metrics, ) -> Self { let apns_client = apns_client.map(Arc::new); let fcm_client = fcm_client.map(Arc::new); @@ -296,12 +296,10 @@ impl PushDispatcher { let delivery_health = Arc::new(DeliveryHealth::default()); // Initialize push capacity metrics - if let Some(ref m) = metrics { - m.set_push_queue_size(0); - m.set_push_queue_capacity(MAX_PENDING_QUEUE_SIZE); - m.set_push_semaphore_available(MAX_CONCURRENT_PUSHES); - m.set_push_concurrency_limit(MAX_CONCURRENT_PUSHES); - } + metrics.set_push_queue_size(0); + metrics.set_push_queue_capacity(MAX_PENDING_QUEUE_SIZE); + metrics.set_push_semaphore_available(MAX_CONCURRENT_PUSHES); + metrics.set_push_concurrency_limit(MAX_CONCURRENT_PUSHES); // Spawn the queue dispatcher task. let worker_context = DispatchWorkerContext { @@ -332,10 +330,8 @@ impl PushDispatcher { } } - fn update_queue_size_metric(metrics: &Option, queue_depth: &AtomicUsize) { - if let Some(metrics) = metrics { - metrics.set_push_queue_size(queue_depth.load(Ordering::SeqCst)); - } + fn update_queue_size_metric(metrics: &Metrics, queue_depth: &AtomicUsize) { + metrics.set_push_queue_size(queue_depth.load(Ordering::SeqCst)); } fn increment_queue_depth(queue_depth: &AtomicUsize, count: usize) { @@ -348,15 +344,13 @@ impl PushDispatcher { }); } - fn update_semaphore_available_metric(metrics: &Option, semaphore: &Semaphore) { - if let Some(metrics) = metrics { - metrics.set_push_semaphore_available(semaphore.available_permits()); - } + fn update_semaphore_available_metric(metrics: &Metrics, semaphore: &Semaphore) { + metrics.set_push_semaphore_available(semaphore.available_permits()); } async fn acquire_push_permit( semaphore: Arc, - metrics: &Option, + metrics: &Metrics, ) -> std::result::Result { let permit = semaphore.clone().acquire_owned().await?; Self::update_semaphore_available_metric(metrics, &semaphore); @@ -367,7 +361,7 @@ impl PushDispatcher { service_name: &str, platform: Platform, outcome: PushSendOutcome, - metrics: &Option, + metrics: &Metrics, delivery_health: &DeliveryHealth, ) { let platform_str = platform_label(platform); @@ -375,9 +369,7 @@ impl PushDispatcher { PushSendOutcome::Sent => { delivery_health.record_processed(platform); trace!(service = service_name, "push notification sent"); - if let Some(m) = metrics { - m.record_push_success(platform_str); - } + metrics.record_push_success(platform_str); } PushSendOutcome::InvalidToken => { // A definitive invalid-token verdict proves the provider @@ -388,9 +380,7 @@ impl PushDispatcher { service = service_name, "push notification failed (invalid token)" ); - if let Some(m) = metrics { - m.record_push_failed(platform_str, "invalid_token"); - } + metrics.record_push_failed(platform_str, "invalid_token"); } PushSendOutcome::RetriesExhausted => { delivery_health.record_hard_failure(platform); @@ -398,9 +388,7 @@ impl PushDispatcher { service = service_name, "push notification failed (retries exhausted)" ); - if let Some(m) = metrics { - m.record_push_failed(platform_str, "retries_exhausted"); - } + metrics.record_push_failed(platform_str, "retries_exhausted"); } } } @@ -410,7 +398,7 @@ impl PushDispatcher { token: Zeroizing, apns_client: Option>, fcm_client: Option>, - metrics: Option, + metrics: Metrics, delivery_health: &DeliveryHealth, backoff_permit: Option<&mut crate::push::retry::BackoffPermit>, ) { @@ -439,9 +427,7 @@ impl PushDispatcher { // path already strips it, but redact again here so // this log sink is safe regardless. debug!(error = %e.redact_transport_url(), "APNs send error"); - if let Some(ref m) = metrics { - m.record_push_failed(platform_str, "error"); - } + metrics.record_push_failed(platform_str, "error"); } } } @@ -467,9 +453,7 @@ impl PushDispatcher { // logging (#172). FCM's URL carries no token, but // keeping the redaction uniform is defense-in-depth. debug!(error = %e.redact_transport_url(), "FCM send error"); - if let Some(ref m) = metrics { - m.record_push_failed(platform_str, "error"); - } + metrics.record_push_failed(platform_str, "error"); } } } @@ -643,9 +627,9 @@ impl PushDispatcher { // Check shutdown after platform/token filtering so post-shutdown requests // always fail while the rejection metric only counts admissible messages. if self.shutting_down.load(Ordering::SeqCst) { - if let Some(ref m) = self.metrics { - m.record_push_queue_rejected(message_count as u64); - } + self.metrics + .record_push_queue_rejected(message_count as u64); + debug!("Dispatcher shutting down, ignoring dispatch request"); return Err(Error::Dispatch("Dispatcher is shutting down".to_string())); } @@ -659,9 +643,9 @@ impl PushDispatcher { .try_reserve_many(message_count) .map_err(|error| match error { mpsc::error::TrySendError::Full(_) => { - if let Some(ref m) = self.metrics { - m.record_push_queue_rejected(message_count as u64); - } + self.metrics + .record_push_queue_rejected(message_count as u64); + warn!( requested = message_count, available = self.sender.capacity(), @@ -672,9 +656,9 @@ impl PushDispatcher { )) } mpsc::error::TrySendError::Closed(_) => { - if let Some(ref m) = self.metrics { - m.record_push_queue_rejected(message_count as u64); - } + self.metrics + .record_push_queue_rejected(message_count as u64); + warn!("Push queue closed, rejecting notification batch"); Error::Dispatch("Push queue closed".to_string()) } @@ -703,9 +687,7 @@ impl PushDispatcher { token: message.token, }); - if let Some(ref m) = self.metrics { - m.record_push_dispatched(platform_str); - } + self.metrics.record_push_dispatched(platform_str); } Ok(message_count) @@ -837,10 +819,9 @@ impl PushDispatcher { self.inflight.wait_idle().await; self.queue_depth.store(0, Ordering::SeqCst); - if let Some(ref m) = self.metrics { - m.set_push_queue_size(0); - m.set_push_semaphore_available(self.semaphore.available_permits()); - } + self.metrics.set_push_queue_size(0); + self.metrics + .set_push_semaphore_available(self.semaphore.available_permits()); debug!("All queued push notifications drained"); } @@ -937,7 +918,7 @@ mod tests { service_account_path: String::new(), project_id: "test-project".to_string(), }; - let metrics = Metrics::default(); + let metrics = Metrics::new().unwrap(); let delivery_health = DeliveryHealth::default(); @@ -946,7 +927,7 @@ mod tests { Zeroizing::new("".to_string()), None, Some(Arc::new(FcmClient::mock(fcm_config, true))), - Some(metrics.clone()), + metrics.clone(), &delivery_health, None, ) @@ -960,29 +941,29 @@ mod tests { #[test] fn test_send_outcome_metrics_distinguish_invalid_token_and_retries_exhausted() { - let metrics = crate::metrics::Metrics::default(); - let metrics_opt = Some(metrics.clone()); + let metrics = crate::metrics::Metrics::new().unwrap(); + let metrics = metrics.clone(); let delivery_health = DeliveryHealth::default(); PushDispatcher::record_send_outcome( "APNs", Platform::Apns, PushSendOutcome::InvalidToken, - &metrics_opt, + &metrics, &delivery_health, ); PushDispatcher::record_send_outcome( "APNs", Platform::Apns, PushSendOutcome::RetriesExhausted, - &metrics_opt, + &metrics, &delivery_health, ); PushDispatcher::record_send_outcome( "FCM", Platform::Fcm, PushSendOutcome::Sent, - &metrics_opt, + &metrics, &delivery_health, ); @@ -1046,7 +1027,7 @@ mod tests { #[test] fn record_send_outcome_updates_delivery_health() { - let metrics_opt = None; + let metrics = Metrics::disabled(); let delivery_health = DeliveryHealth::default(); for _ in 0..DELIVERY_FAILURE_STREAK_THRESHOLD { @@ -1054,7 +1035,7 @@ mod tests { "APNs", Platform::Apns, PushSendOutcome::RetriesExhausted, - &metrics_opt, + &metrics, &delivery_health, ); } @@ -1069,7 +1050,7 @@ mod tests { "APNs", Platform::Apns, PushSendOutcome::InvalidToken, - &metrics_opt, + &metrics, &delivery_health, ); assert!(delivery_health.is_delivering(Platform::Apns)); @@ -1323,12 +1304,9 @@ mod tests { }; let fcm_client = FcmClient::mock(fcm_config, true); - let metrics = Metrics::default(); - let dispatcher = PushDispatcher::with_metrics( - Some(apns_client), - Some(fcm_client), - Some(metrics.clone()), - ); + let metrics = Metrics::new().unwrap(); + let dispatcher = + PushDispatcher::with_metrics(Some(apns_client), Some(fcm_client), metrics.clone()); // Dispatch both APNs and FCM payloads let payloads = vec![ @@ -1389,11 +1367,11 @@ mod tests { payload_mode: Default::default(), }; - let metrics = Metrics::default(); + let metrics = Metrics::new().unwrap(); let dispatcher = PushDispatcher::with_metrics( Some(ApnsClient::mock(apns_config, true)), None, - Some(metrics.clone()), + metrics.clone(), ); let permits = dispatcher @@ -1535,8 +1513,8 @@ mod tests { async fn test_capacity_metrics_initialized() { use crate::metrics::Metrics; - let metrics = Metrics::default(); - let _dispatcher = PushDispatcher::with_metrics(None, None, Some(metrics.clone())); + let metrics = Metrics::new().unwrap(); + let _dispatcher = PushDispatcher::with_metrics(None, None, metrics.clone()); assert_eq!(queue_size_metric_value(&metrics), 0); assert_eq!( @@ -1557,13 +1535,13 @@ mod tests { async fn test_semaphore_available_metric_updates_on_permit_acquire() { use crate::metrics::Metrics; - let metrics = Metrics::default(); - let metrics_opt = Some(metrics.clone()); + let metrics = Metrics::new().unwrap(); + let metrics = metrics.clone(); let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_PUSHES)); metrics.set_push_semaphore_available(MAX_CONCURRENT_PUSHES); - let permit = PushDispatcher::acquire_push_permit(semaphore.clone(), &metrics_opt) + let permit = PushDispatcher::acquire_push_permit(semaphore.clone(), &metrics) .await .expect("permit should be acquired"); @@ -1573,7 +1551,7 @@ mod tests { ); drop(permit); - PushDispatcher::update_semaphore_available_metric(&metrics_opt, &semaphore); + PushDispatcher::update_semaphore_available_metric(&metrics, &semaphore); assert_eq!( gauge_metric_value(&metrics, "transponder_push_semaphore_available"), @@ -1587,7 +1565,7 @@ mod tests { use crate::metrics::Metrics; use crate::push::ApnsClient; - let metrics = Metrics::default(); + let metrics = Metrics::new().unwrap(); let dispatcher = PushDispatcher::with_metrics( Some(ApnsClient::mock( ApnsConfig { @@ -1602,7 +1580,7 @@ mod tests { true, )), None, - Some(metrics.clone()), + metrics.clone(), ); dispatcher.wait_for_completion().await; @@ -1626,7 +1604,7 @@ mod tests { use crate::metrics::Metrics; use crate::push::ApnsClient; - let metrics = Metrics::default(); + let metrics = Metrics::new().unwrap(); let dispatcher = PushDispatcher::with_metrics( Some(ApnsClient::mock( ApnsConfig { @@ -1641,7 +1619,7 @@ mod tests { true, )), None, - Some(metrics.clone()), + metrics.clone(), ); dispatcher.wait_for_completion().await; @@ -1832,9 +1810,8 @@ mod tests { payload_mode: Default::default(), }; let apns_client = ApnsClient::mock(apns_config, true); - let metrics = Metrics::default(); - let dispatcher = - PushDispatcher::with_metrics(Some(apns_client), None, Some(metrics.clone())); + let metrics = Metrics::new().unwrap(); + let dispatcher = PushDispatcher::with_metrics(Some(apns_client), None, metrics.clone()); let payloads = repeated_apns_payloads(MAX_PENDING_QUEUE_SIZE + 1); @@ -1889,11 +1866,11 @@ mod tests { payload_mode: Default::default(), }; - let metrics = Metrics::default(); + let metrics = Metrics::new().unwrap(); let dispatcher = PushDispatcher::with_metrics( Some(ApnsClient::mock(apns_config, true)), None, - Some(metrics.clone()), + metrics.clone(), ); // Many small batches maximize the number of opportunities for a worker @@ -2007,7 +1984,7 @@ mod tests { } }, Some(&mut backoff_permit), - None, + Metrics::disabled(), ) .await; assert!(matches!(result, Ok(PushSendOutcome::Sent))); diff --git a/src/push/fcm.rs b/src/push/fcm.rs index bc18588..fee5f09 100644 --- a/src/push/fcm.rs +++ b/src/push/fcm.rs @@ -261,7 +261,7 @@ pub(crate) struct FcmTokenGenerator { http_client: Client, service_account: Option, encoding_key: Option, - metrics: Option, + metrics: Metrics, /// Test-only override for the OAuth token endpoint URL. #[cfg(test)] test_oauth_token_url: Option, @@ -384,9 +384,7 @@ impl AuthTokenGenerator for FcmTokenGenerator { let ttl = token_cache_lifetime(token_response.expires_in); - if let Some(metrics) = &self.metrics { - metrics.record_auth_token_refresh("fcm_oauth"); - } + self.metrics.record_auth_token_refresh("fcm_oauth"); trace!("Refreshed FCM access token"); Ok(MintedToken { @@ -401,7 +399,7 @@ pub struct FcmClient { pub(crate) config: FcmConfig, pub(crate) http_client: Client, pub(crate) token_cache: TokenCache, - pub(crate) metrics: Option, + pub(crate) metrics: Metrics, /// Test-only override for the FCM v1 API base URL (scheme + host + port). #[cfg(test)] pub(crate) test_fcm_api_base_url: Option, @@ -411,11 +409,11 @@ impl FcmClient { /// Create a new FCM client. #[allow(dead_code)] pub async fn new(config: FcmConfig) -> Result { - Self::with_metrics(config, None).await + Self::with_metrics(config, Metrics::disabled()).await } /// Create a new FCM client with metrics. - pub async fn with_metrics(config: FcmConfig, metrics: Option) -> Result { + pub async fn with_metrics(config: FcmConfig, metrics: Metrics) -> Result { let http_client = Client::builder().timeout(Duration::from_secs(30)).build()?; // Load service account if configured @@ -529,12 +527,12 @@ impl FcmClient { "FCM", || self.send_once(&device_token), backoff_permit, - self.metrics.as_ref(), + self.metrics.clone(), ) .await; - if let Some(metrics) = &self.metrics { - metrics.observe_push_duration("fcm", start.elapsed().as_secs_f64()); - } + self.metrics + .observe_push_duration("fcm", start.elapsed().as_secs_f64()); + result } @@ -639,9 +637,8 @@ impl FcmClient { ) -> SendAttemptResult { let status = response.status(); - if let Some(metrics) = &self.metrics { - metrics.record_push_response_status("fcm", status.as_u16()); - } + self.metrics + .record_push_response_status("fcm", status.as_u16()); match status.as_u16() { 200 => { @@ -756,7 +753,7 @@ impl FcmClient { // transport errors never carry a target into logs (#172). .map_err(|e| Error::from(e).redact_transport_url()) }, - self.metrics.as_ref(), + &self.metrics, ) .await { @@ -848,7 +845,7 @@ impl FcmClient { http_client: http_client.clone(), service_account, encoding_key, - metrics: None, + metrics: Metrics::disabled(), test_oauth_token_url: None, }); @@ -856,7 +853,7 @@ impl FcmClient { config, http_client, token_cache, - metrics: None, + metrics: Metrics::disabled(), test_fcm_api_base_url: None, } } @@ -1329,7 +1326,7 @@ mod tests { project_id: "test-project".to_string(), }; let mut client = FcmClient::mock(config, false); - client.metrics = Some(metrics.clone()); + client.metrics = metrics.clone(); let response = Client::new() .post(format!( @@ -1388,7 +1385,7 @@ mod tests { project_id: "test-project".to_string(), }; let mut client = FcmClient::mock(config, true); - client.metrics = Some(metrics.clone()); + client.metrics = metrics.clone(); client.test_fcm_api_base_url = Some(mock_server.uri()); client .seed_token("cached", SystemTime::now() + Duration::from_secs(3600)) @@ -2688,7 +2685,7 @@ LTP/MQIxLydQxT4+jx2NBu0= http_client: http_client.clone(), service_account: Some(sa), encoding_key: Some(encoding_key), - metrics: None, + metrics: Metrics::disabled(), test_oauth_token_url: None, }); @@ -2696,7 +2693,7 @@ LTP/MQIxLydQxT4+jx2NBu0= config, http_client, token_cache, - metrics: None, + metrics: Metrics::disabled(), test_fcm_api_base_url: None, } } @@ -2786,7 +2783,7 @@ LTP/MQIxLydQxT4+jx2NBu0= .expect("http client"), service_account: Some(sa), encoding_key: Some(encoding_key), - metrics: None, + metrics: Metrics::disabled(), test_oauth_token_url: None, } } diff --git a/src/push/retry.rs b/src/push/retry.rs index cb35406..22e79f3 100644 --- a/src/push/retry.rs +++ b/src/push/retry.rs @@ -38,10 +38,8 @@ impl BackoffPermit { self.permit = None; } - fn update_available_metric(&self, metrics: Option<&Metrics>) { - if let Some(metrics) = metrics { - metrics.set_push_semaphore_available(self.semaphore.available_permits()); - } + fn update_available_metric(&self, metrics: &Metrics) { + metrics.set_push_semaphore_available(self.semaphore.available_permits()); } /// Re-acquire a permit before the next attempt. Awaits if all slots are busy. @@ -180,7 +178,7 @@ pub async fn with_retry( service_name: &str, mut operation: F, backoff_permit: Option<&mut BackoffPermit>, - metrics: Option<&Metrics>, + metrics: Metrics, ) -> crate::error::Result where F: FnMut() -> Fut, @@ -203,9 +201,7 @@ where SendAttemptResult::AuthRejected(error) if !auth_retry_used => { auth_retry_used = true; - if let Some(metrics) = metrics { - metrics.record_push_retry(service_name.to_lowercase().as_str()); - } + metrics.record_push_retry(service_name.to_lowercase().as_str()); // Retry immediately: the rejection is not backpressure, and // the client already evicted the rejected credential, so the @@ -229,9 +225,7 @@ where } if retries < config.max_retries => { retries += 1; - if let Some(metrics) = metrics { - metrics.record_push_retry(service_name.to_lowercase().as_str()); - } + metrics.record_push_retry(service_name.to_lowercase().as_str()); // Honor provider-supplied Retry-After values, but floor zero // or tiny values, cap at MAX_RETRY_AFTER (untrusted input), @@ -253,14 +247,14 @@ where // requests can proceed, then re-acquire it before retrying. if let Some(permit) = backoff_permit.as_deref_mut() { permit.release(); - permit.update_available_metric(metrics); + permit.update_available_metric(&metrics); sleep(wait_duration).await; if permit.reacquire().await.is_err() { // Semaphore closed (shutdown): shed this request without // classifying it as a dead device token. return Ok(PushSendOutcome::RetriesExhausted); } - permit.update_available_metric(metrics); + permit.update_available_metric(&metrics); } else { sleep(wait_duration).await; } @@ -352,7 +346,7 @@ pub async fn with_transport_retry( config: &RetryConfig, service_name: &str, mut operation: F, - metrics: Option<&Metrics>, + metrics: &Metrics, ) -> crate::error::Result where F: FnMut() -> Fut, @@ -369,9 +363,7 @@ where { retries += 1; - if let Some(metrics) = metrics { - metrics.record_push_retry(service_name.to_lowercase().as_str()); - } + metrics.record_push_retry(service_name.to_lowercase().as_str()); // A reqwest error can embed the request URL, and the APNs URL // contains the raw device token; strip it before the error can @@ -704,7 +696,7 @@ mod tests { } }, None, - None, + Metrics::disabled(), ) .await; @@ -735,7 +727,7 @@ mod tests { } }, None, - None, + Metrics::disabled(), ) .await; @@ -775,7 +767,7 @@ mod tests { } }, None, - None, + Metrics::disabled(), ) .await; @@ -794,6 +786,7 @@ mod tests { initial_backoff: Duration::from_millis(1), }; let client = Client::new(); + let metrics = Metrics::disabled(); let result: crate::error::Result = with_transport_retry( &config, @@ -812,7 +805,7 @@ mod tests { Err(Error::from(error)) } }, - None, + &metrics, ) .await; @@ -834,6 +827,7 @@ mod tests { initial_backoff: Duration::from_millis(1), }; let client = Client::new(); + let metrics = Metrics::disabled(); let result: crate::error::Result = with_transport_retry( &config, @@ -850,7 +844,7 @@ mod tests { Err(Error::from(error)) } }, - None, + &metrics, ) .await; @@ -880,7 +874,7 @@ mod tests { } }, None, - None, + Metrics::disabled(), ) .await; @@ -916,7 +910,7 @@ mod tests { } }, None, - None, + Metrics::disabled(), ) .await; @@ -948,7 +942,7 @@ mod tests { } }, None, - None, + Metrics::disabled(), ) .await; @@ -977,7 +971,7 @@ mod tests { } }, None, - None, + Metrics::disabled(), ) .await; @@ -1014,7 +1008,7 @@ mod tests { } }, None, - None, + Metrics::disabled(), ) .await; @@ -1078,7 +1072,7 @@ mod tests { } }, None, - None, + Metrics::disabled(), ) .await }); @@ -1138,7 +1132,7 @@ mod tests { "test", || async { SendAttemptResult::Success(false) }, None, - None, + Metrics::disabled(), ) .await; @@ -1176,7 +1170,7 @@ mod tests { } }, None, - Some(&metrics), + metrics.clone(), ) .await; @@ -1226,7 +1220,7 @@ mod tests { } }, Some(&mut bp), - None, + Metrics::disabled(), ) .await }); @@ -1262,7 +1256,7 @@ mod tests { let sem = Arc::new(Semaphore::new(1)); let permit = sem.clone().acquire_owned().await.unwrap(); let mut bp = BackoffPermit::new(sem.clone(), permit); - let metrics = Metrics::default(); + let metrics = Metrics::new().unwrap(); metrics.set_push_semaphore_available(0); let config = RetryConfig { @@ -1309,7 +1303,7 @@ mod tests { } }, Some(&mut bp), - Some(&metrics_for_task), + metrics_for_task.clone(), ) .await }); @@ -1348,6 +1342,7 @@ mod tests { let attempt_count = Arc::new(AtomicU32::new(0)); let attempt_count_clone = attempt_count.clone(); let client = Client::new(); + let metrics = Metrics::disabled(); let result: crate::error::Result = with_transport_retry( &config, @@ -1365,7 +1360,7 @@ mod tests { } } }, - None, + &metrics, ) .await; @@ -1383,6 +1378,7 @@ mod tests { let attempt_count = Arc::new(AtomicU32::new(0)); let attempt_count_clone = attempt_count.clone(); let client = Client::new(); + let metrics = Metrics::disabled(); let result: crate::error::Result = with_transport_retry( &config, @@ -1396,7 +1392,7 @@ mod tests { Err(Error::from(error)) } }, - None, + &metrics, ) .await; @@ -1413,6 +1409,7 @@ mod tests { let attempt_count = Arc::new(AtomicU32::new(0)); let attempt_count_clone = attempt_count.clone(); let client = Client::new(); + let metrics = Metrics::disabled(); // A request to an unsupported URL scheme produces a `reqwest::Error` // whose `is_connect()` is false: the request was never dispatched to a @@ -1435,7 +1432,7 @@ mod tests { Err(Error::from(error)) } }, - None, + &metrics, ) .await; diff --git a/src/server/health.rs b/src/server/health.rs index f539e71..d6de2f0 100644 --- a/src/server/health.rs +++ b/src/server/health.rs @@ -72,7 +72,7 @@ struct ReadyResponse { struct HealthState { relay_client: Arc, push_dispatcher: Arc, - metrics: Option, + metrics: Metrics, } /// Health check HTTP server. @@ -80,7 +80,7 @@ pub struct HealthServer { config: HealthConfig, relay_client: Arc, push_dispatcher: Arc, - metrics: Option, + metrics: Metrics, } impl HealthServer { @@ -89,7 +89,7 @@ impl HealthServer { config: HealthConfig, relay_client: Arc, push_dispatcher: Arc, - metrics: Option, + metrics: Metrics, ) -> Self { Self { config, @@ -114,7 +114,7 @@ impl HealthServer { /// Only when both are disabled is nothing bound. pub async fn bind(&self) -> Result> { let serve_health = self.config.enabled; - let serve_metrics = self.metrics.is_some(); + let serve_metrics = self.metrics.is_enabled(); if !serve_health && !serve_metrics { info!("Health server and metrics disabled; not binding a listener"); @@ -175,7 +175,7 @@ impl HealthServer { .route("/health", get(health_handler)) .route("/ready", get(ready_handler)); } - if self.metrics.is_some() { + if self.metrics.is_enabled() { router = router.route("/metrics", get(metrics_handler)); } @@ -277,15 +277,15 @@ async fn ready_handler(State(state): State>) -> impl IntoRespon /// Prometheus metrics handler. async fn metrics_handler(State(state): State>) -> impl IntoResponse { - let Some(metrics) = &state.metrics else { + if !state.metrics.is_enabled() { return ( StatusCode::NOT_FOUND, [("content-type", "text/plain")], vec![], ); - }; + } - let metric_families = metrics.gather(); + let metric_families = state.metrics.gather(); let encoder = TextEncoder::new(); let mut buffer = vec![]; @@ -350,7 +350,7 @@ mod tests { let relay_client = Arc::new(RelayClient::new(keys, relay_config).await.unwrap()); let push_dispatcher = Arc::new(PushDispatcher::new(None, None)); - HealthServer::new(config, relay_client, push_dispatcher, None) + HealthServer::new(config, relay_client, push_dispatcher, Metrics::disabled()) } #[tokio::test] @@ -484,7 +484,7 @@ mod tests { bind_address: addr.to_string(), }; - let server = HealthServer::new(config, relay_client, push_dispatcher, None); + let server = HealthServer::new(config, relay_client, push_dispatcher, Metrics::disabled()); // Create shutdown channel let (shutdown_tx, shutdown_rx) = watch::channel(false); @@ -536,7 +536,7 @@ mod tests { bind_address: addr.to_string(), }; - let server = HealthServer::new(config, relay_client, push_dispatcher, None); + let server = HealthServer::new(config, relay_client, push_dispatcher, Metrics::disabled()); let (shutdown_tx, shutdown_rx) = watch::channel(false); @@ -585,7 +585,7 @@ mod tests { bind_address: "127.0.0.1:0".to_string(), }; - let server = HealthServer::new(config, relay_client, push_dispatcher, None); + let server = HealthServer::new(config, relay_client, push_dispatcher, Metrics::disabled()); let (shutdown_tx, shutdown_rx) = watch::channel(false); @@ -630,7 +630,7 @@ mod tests { bind_address: invalid_address.to_string(), }; - let server = HealthServer::new(config, relay_client, push_dispatcher, None); + let server = HealthServer::new(config, relay_client, push_dispatcher, Metrics::disabled()); let (_shutdown_tx, shutdown_rx) = watch::channel(false); @@ -666,9 +666,9 @@ mod tests { }; let relay_client = Arc::new(RelayClient::new(keys, relay_config).await.unwrap()); let push_dispatcher = Arc::new(PushDispatcher::new(None, None)); - let metrics = Metrics::default(); + let metrics = Metrics::new().unwrap(); metrics.init_server_info("0.0.0"); - let metrics = Some(metrics); + let metrics = metrics; // Find a free port let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -713,8 +713,7 @@ mod tests { }; let relay_client = Arc::new(RelayClient::new(keys, relay_config).await.unwrap()); let push_dispatcher = Arc::new(PushDispatcher::new(None, None)); - // No metrics provided - let metrics = None; + let metrics = Metrics::disabled(); // Find a free port let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -792,7 +791,7 @@ mod tests { bind_address: addr.to_string(), }; - let server = HealthServer::new(config, relay_client, push_dispatcher, None); + let server = HealthServer::new(config, relay_client, push_dispatcher, Metrics::disabled()); let (shutdown_tx, shutdown_rx) = watch::channel(false); @@ -869,7 +868,7 @@ mod tests { bind_address: addr.to_string(), }; - let server = HealthServer::new(config, relay_client, push_dispatcher, None); + let server = HealthServer::new(config, relay_client, push_dispatcher, Metrics::disabled()); let (shutdown_tx, shutdown_rx) = watch::channel(false); let server_handle = tokio::spawn(async move { server.run(shutdown_rx).await }); @@ -906,7 +905,7 @@ mod tests { }; let relay_client = Arc::new(RelayClient::new(keys, relay_config).await.unwrap()); let push_dispatcher = Arc::new(PushDispatcher::new(None, None)); - let metrics = Metrics::default(); + let metrics = Metrics::new().unwrap(); metrics.init_server_info("0.0.0"); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -918,7 +917,7 @@ mod tests { bind_address: addr.to_string(), }; - let server = HealthServer::new(config, relay_client, push_dispatcher, Some(metrics)); + let server = HealthServer::new(config, relay_client, push_dispatcher, metrics); let (shutdown_tx, shutdown_rx) = watch::channel(false); let server_handle = tokio::spawn(async move { server.run(shutdown_rx).await }); @@ -972,7 +971,7 @@ mod tests { }, relay_client, push_dispatcher, - Some(Metrics::default()), + Metrics::new().unwrap(), ); let listener = server.bind().await.unwrap(); @@ -1016,7 +1015,7 @@ mod tests { let state = Arc::new(HealthState { relay_client, push_dispatcher, - metrics: None, + metrics: Metrics::disabled(), }); // Defensive guard: the route is only mounted when a collector exists, @@ -1055,7 +1054,7 @@ mod tests { bind_address: addr.to_string(), }; - let server = HealthServer::new(config, relay_client, push_dispatcher, None); + let server = HealthServer::new(config, relay_client, push_dispatcher, Metrics::disabled()); let (shutdown_tx, shutdown_rx) = watch::channel(false); let server_handle = tokio::spawn(async move { server.run(shutdown_rx).await }); From ac1962e9945a065652d3605416b8c17e501879f6 Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:09:28 +0100 Subject: [PATCH 06/10] Split push dispatcher into focused submodules. Extract delivery health and in-flight task tracking from the monolithic dispatcher so the queue/worker logic stays easier to navigate and bisect. Co-authored-by: Cursor --- src/push/dispatcher/health.rs | 72 +++++++++ src/push/dispatcher/inflight.rs | 80 +++++++++ src/push/{dispatcher.rs => dispatcher/mod.rs} | 152 +----------------- 3 files changed, 159 insertions(+), 145 deletions(-) create mode 100644 src/push/dispatcher/health.rs create mode 100644 src/push/dispatcher/inflight.rs rename src/push/{dispatcher.rs => dispatcher/mod.rs} (92%) diff --git a/src/push/dispatcher/health.rs b/src/push/dispatcher/health.rs new file mode 100644 index 0000000..8a60901 --- /dev/null +++ b/src/push/dispatcher/health.rs @@ -0,0 +1,72 @@ +//! Passive per-provider delivery health tracking. + +use std::sync::atomic::{AtomicU32, Ordering}; + +use crate::crypto::Platform; + +/// Number of consecutive hard delivery failures after which a provider is +/// reported as not delivering (see [`DeliveryHealth`]). +/// +/// "Hard" failures are outcomes indicating the provider itself is refusing or +/// failing requests: permanent send errors (which include authentication +/// rejections such as a revoked APNs signing key or an expired FCM service +/// account) and exhausted retry budgets. Invalid device tokens do NOT count — +/// a definitive invalid-token verdict proves the provider authenticated and +/// processed the request. The threshold trades detection speed against +/// flapping: five hard failures in a row with no intervening success is a +/// sustained outage signal, not an isolated transient blip. +pub const DELIVERY_FAILURE_STREAK_THRESHOLD: u32 = 5; + +/// Passive per-provider delivery-health signal derived from real send +/// outcomes. +/// +/// Tracks, for each push provider, the current streak of *consecutive* hard +/// send failures. The streak grows on permanent errors and exhausted retries, +/// and resets to zero whenever the provider demonstrably processes a request +/// (successful send or a definitive invalid-token verdict). The readiness +/// endpoint uses [`DeliveryHealth::is_delivering`] to gate `/ready` on live +/// delivery capability instead of static configuration alone. +/// +/// The signal is passive: it observes outcomes of real traffic and never +/// probes the providers. If push traffic stops entirely, the last observed +/// state is retained until the next send. +#[derive(Debug, Default)] +pub struct DeliveryHealth { + apns_hard_failure_streak: AtomicU32, + fcm_hard_failure_streak: AtomicU32, +} + +impl DeliveryHealth { + fn streak(&self, platform: Platform) -> &AtomicU32 { + match platform { + Platform::Apns => &self.apns_hard_failure_streak, + Platform::Fcm => &self.fcm_hard_failure_streak, + } + } + + /// Record that the provider processed a request (successful send, or a + /// definitive invalid-token verdict), ending any hard-failure streak. + pub(crate) fn record_processed(&self, platform: Platform) { + self.streak(platform).store(0, Ordering::SeqCst); + } + + /// Record a hard send failure (permanent error or exhausted retries). + /// + /// Saturates instead of wrapping so an arbitrarily long outage can never + /// roll the streak back over to "delivering". + pub(crate) fn record_hard_failure(&self, platform: Platform) { + let _ = self + .streak(platform) + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |streak| { + Some(streak.saturating_add(1)) + }); + } + + /// Whether the provider is currently considered to be delivering: its + /// consecutive hard-failure streak is below + /// [`DELIVERY_FAILURE_STREAK_THRESHOLD`]. + #[must_use] + pub fn is_delivering(&self, platform: Platform) -> bool { + self.streak(platform).load(Ordering::SeqCst) < DELIVERY_FAILURE_STREAK_THRESHOLD + } +} diff --git a/src/push/dispatcher/inflight.rs b/src/push/dispatcher/inflight.rs new file mode 100644 index 0000000..a13657d --- /dev/null +++ b/src/push/dispatcher/inflight.rs @@ -0,0 +1,80 @@ +//! In-flight send task lifetime tracking. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use tokio::sync::Notify; + +/// Tracks the number of spawned send tasks that have not yet finished, so that +/// graceful shutdown can wait for them to complete. +/// +/// This is deliberately decoupled from the concurrency [`Semaphore`]: a send +/// task that is in a retry backoff sleep releases its concurrency permit (see +/// [`crate::push::retry::BackoffPermit`]) so the slot can be reused, but the +/// task is still alive and may re-acquire a permit and send again. Counting +/// permits is therefore *not* a sound proof that all send tasks have finished. +/// This tracker counts task lifetimes end-to-end instead. +pub(crate) struct InFlightTracker { + count: AtomicUsize, + idle: Notify, +} + +impl InFlightTracker { + pub(crate) fn new() -> Self { + Self { + count: AtomicUsize::new(0), + idle: Notify::new(), + } + } + + /// Register a newly spawned send task. The returned guard decrements the + /// in-flight count when dropped (i.e. when the send task finishes) and + /// wakes any shutdown waiter once the count reaches zero. + pub(crate) fn enter(self: &Arc) -> InFlightGuard { + self.count.fetch_add(1, Ordering::SeqCst); + InFlightGuard { + tracker: self.clone(), + } + } + + /// Wait until no send tasks are in flight. + /// + /// Uses the register-before-check pattern so a task that finishes between + /// the count load and observing the notification cannot be missed: the + /// `Notified` future is *enabled* (waiter registered) before the count is + /// read. `notify_waiters()` does not store a permit, so the waiter must be + /// registered first; `Notified::enable()` registers it eagerly without + /// awaiting, which closes the lost-wakeup window. + pub(crate) async fn wait_idle(&self) { + loop { + let notified = self.idle.notified(); + tokio::pin!(notified); + // Register this waiter before reading the count. + notified.as_mut().enable(); + if self.count.load(Ordering::SeqCst) == 0 { + return; + } + notified.await; + } + } + + #[cfg(test)] + pub(crate) fn in_flight_count(&self) -> usize { + self.count.load(Ordering::SeqCst) + } +} + +/// RAII guard returned by [`InFlightTracker::enter`]; decrements the in-flight +/// count and signals idle when the last in-flight task finishes. +pub(crate) struct InFlightGuard { + tracker: Arc, +} + +impl Drop for InFlightGuard { + fn drop(&mut self) { + if self.tracker.count.fetch_sub(1, Ordering::SeqCst) == 1 { + // Transitioned to zero in-flight tasks; wake any shutdown waiter. + self.tracker.idle.notify_waiters(); + } + } +} diff --git a/src/push/dispatcher.rs b/src/push/dispatcher/mod.rs similarity index 92% rename from src/push/dispatcher.rs rename to src/push/dispatcher/mod.rs index fec2390..2451980 100644 --- a/src/push/dispatcher.rs +++ b/src/push/dispatcher/mod.rs @@ -17,9 +17,8 @@ //! request serialization. use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use tokio::sync::Notify; use tokio::sync::Semaphore; use tokio::sync::mpsc; use tokio::task::JoinHandle; @@ -63,72 +62,11 @@ const MAX_LIVE_SEND_TASKS: usize = MAX_CONCURRENT_PUSHES * LIVE_TASK_MULTIPLIER; /// new notification batches are rejected to protect against DoS attacks. const MAX_PENDING_QUEUE_SIZE: usize = 10_000; -/// Number of consecutive hard delivery failures after which a provider is -/// reported as not delivering (see [`DeliveryHealth`]). -/// -/// "Hard" failures are outcomes indicating the provider itself is refusing or -/// failing requests: permanent send errors (which include authentication -/// rejections such as a revoked APNs signing key or an expired FCM service -/// account) and exhausted retry budgets. Invalid device tokens do NOT count — -/// a definitive invalid-token verdict proves the provider authenticated and -/// processed the request. The threshold trades detection speed against -/// flapping: five hard failures in a row with no intervening success is a -/// sustained outage signal, not an isolated transient blip. -pub const DELIVERY_FAILURE_STREAK_THRESHOLD: u32 = 5; - -/// Passive per-provider delivery-health signal derived from real send -/// outcomes. -/// -/// Tracks, for each push provider, the current streak of *consecutive* hard -/// send failures. The streak grows on permanent errors and exhausted retries, -/// and resets to zero whenever the provider demonstrably processes a request -/// (successful send or a definitive invalid-token verdict). The readiness -/// endpoint uses [`DeliveryHealth::is_delivering`] to gate `/ready` on live -/// delivery capability instead of static configuration alone. -/// -/// The signal is passive: it observes outcomes of real traffic and never -/// probes the providers. If push traffic stops entirely, the last observed -/// state is retained until the next send. -#[derive(Debug, Default)] -pub struct DeliveryHealth { - apns_hard_failure_streak: AtomicU32, - fcm_hard_failure_streak: AtomicU32, -} +mod health; +mod inflight; -impl DeliveryHealth { - fn streak(&self, platform: Platform) -> &AtomicU32 { - match platform { - Platform::Apns => &self.apns_hard_failure_streak, - Platform::Fcm => &self.fcm_hard_failure_streak, - } - } - - /// Record that the provider processed a request (successful send, or a - /// definitive invalid-token verdict), ending any hard-failure streak. - pub(crate) fn record_processed(&self, platform: Platform) { - self.streak(platform).store(0, Ordering::SeqCst); - } - - /// Record a hard send failure (permanent error or exhausted retries). - /// - /// Saturates instead of wrapping so an arbitrarily long outage can never - /// roll the streak back over to "delivering". - pub(crate) fn record_hard_failure(&self, platform: Platform) { - let _ = self - .streak(platform) - .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |streak| { - Some(streak.saturating_add(1)) - }); - } - - /// Whether the provider is currently considered to be delivering: its - /// consecutive hard-failure streak is below - /// [`DELIVERY_FAILURE_STREAK_THRESHOLD`]. - #[must_use] - pub fn is_delivering(&self, platform: Platform) -> bool { - self.streak(platform).load(Ordering::SeqCst) < DELIVERY_FAILURE_STREAK_THRESHOLD - } -} +pub use health::{DELIVERY_FAILURE_STREAK_THRESHOLD, DeliveryHealth}; +pub(crate) use inflight::InFlightTracker; /// Metrics/logging label for a push platform. fn platform_label(platform: Platform) -> &'static str { @@ -139,18 +77,11 @@ fn platform_label(platform: Platform) -> &'static str { } /// Internal message for the push queue. -/// -/// # Security -/// -/// The token field is wrapped in `Zeroizing` while queued and is moved -/// intact into the provider client when the message is sent. enum PushMessage { - /// Send a notification to the given platform with the given token. Send { platform: Platform, token: Zeroizing, }, - /// Shutdown signal for the dispatcher task. Shutdown, } @@ -159,75 +90,6 @@ struct QueuedPushMessage { token: Zeroizing, } -/// Tracks the number of spawned send tasks that have not yet finished, so that -/// graceful shutdown can wait for them to complete. -/// -/// This is deliberately decoupled from the concurrency [`Semaphore`]: a send -/// task that is in a retry backoff sleep releases its concurrency permit (see -/// [`crate::push::retry::BackoffPermit`]) so the slot can be reused, but the -/// task is still alive and may re-acquire a permit and send again. Counting -/// permits is therefore *not* a sound proof that all send tasks have finished. -/// This tracker counts task lifetimes end-to-end instead. -struct InFlightTracker { - count: AtomicUsize, - idle: Notify, -} - -impl InFlightTracker { - fn new() -> Self { - Self { - count: AtomicUsize::new(0), - idle: Notify::new(), - } - } - - /// Register a newly spawned send task. The returned guard decrements the - /// in-flight count when dropped (i.e. when the send task finishes) and - /// wakes any shutdown waiter once the count reaches zero. - fn enter(self: &Arc) -> InFlightGuard { - self.count.fetch_add(1, Ordering::SeqCst); - InFlightGuard { - tracker: self.clone(), - } - } - - /// Wait until no send tasks are in flight. - /// - /// Uses the register-before-check pattern so a task that finishes between - /// the count load and observing the notification cannot be missed: the - /// `Notified` future is *enabled* (waiter registered) before the count is - /// read. `notify_waiters()` does not store a permit, so the waiter must be - /// registered first; `Notified::enable()` registers it eagerly without - /// awaiting, which closes the lost-wakeup window. - async fn wait_idle(&self) { - loop { - let notified = self.idle.notified(); - tokio::pin!(notified); - // Register this waiter before reading the count. - notified.as_mut().enable(); - if self.count.load(Ordering::SeqCst) == 0 { - return; - } - notified.await; - } - } -} - -/// RAII guard returned by [`InFlightTracker::enter`]; decrements the in-flight -/// count and signals idle when the last in-flight task finishes. -struct InFlightGuard { - tracker: Arc, -} - -impl Drop for InFlightGuard { - fn drop(&mut self) { - if self.tracker.count.fetch_sub(1, Ordering::SeqCst) == 1 { - // Transitioned to zero in-flight tasks; wake any shutdown waiter. - self.tracker.idle.notify_waiters(); - } - } -} - /// Cloneable shared state handed to the queue-draining dispatcher loop and, in /// turn, to each spawned send task. Groups the fields so the spawn path stays a /// single argument rather than a long positional list. @@ -1907,7 +1769,7 @@ mod tests { // Hold a guard, then assert wait_idle does NOT return until it is dropped. let guard = tracker.enter(); - assert_eq!(tracker.count.load(Ordering::SeqCst), 1); + assert_eq!(tracker.in_flight_count(), 1); let waiter_tracker = tracker.clone(); let waiter = tokio::spawn(async move { waiter_tracker.wait_idle().await }); @@ -1925,7 +1787,7 @@ mod tests { .expect("wait_idle should complete after the last guard is dropped") .expect("waiter task should not panic"); - assert_eq!(tracker.count.load(Ordering::SeqCst), 0); + assert_eq!(tracker.in_flight_count(), 0); } /// Regression for transponder#65: graceful shutdown must wait for send tasks From 4f52b08bfb6bbbe5add7ccd8f135fb3864fb9f42 Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:12:17 +0100 Subject: [PATCH 07/10] Replace EventProcessor test constructors with a builder. Collapse new, with_cache_size, and with_full_config into EventProcessorBuilder so production code only exposes with_replay_config. Co-authored-by: Cursor --- src/app.rs | 9 +- src/nostr/events/mod.rs | 3 + src/nostr/events/processor.rs | 216 +++++++++++++++------------------- 3 files changed, 100 insertions(+), 128 deletions(-) diff --git a/src/app.rs b/src/app.rs index 96ac545..a7d37e6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -851,6 +851,7 @@ pub fn init_logging(config: &crate::config::LoggingConfig) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use crate::nostr::events::EventProcessorBuilder; use crate::test_support::{default_server_config, server_config_with}; use axum::{Router, http::StatusCode, routing::get}; use std::io::Write; @@ -1044,11 +1045,9 @@ mod tests { .expect("valid secret key"); let token_decryptor = TokenDecryptor::new(&mut secp_secret_key); let push_dispatcher = Arc::new(PushDispatcher::new(None, None)); - Arc::new(EventProcessor::new( - nip59_handler, - token_decryptor, - push_dispatcher, - )) + Arc::new( + EventProcessorBuilder::new(nip59_handler, token_decryptor, push_dispatcher).build(), + ) } fn test_event_notification() -> RelayPoolNotification { diff --git a/src/nostr/events/mod.rs b/src/nostr/events/mod.rs index 137d9fe..f3c5757 100644 --- a/src/nostr/events/mod.rs +++ b/src/nostr/events/mod.rs @@ -9,6 +9,9 @@ mod processor; pub use processor::{EventProcessor, ReplayProtectionConfig, TokenRateLimitConfig}; +#[cfg(test)] +pub(crate) use processor::EventProcessorBuilder; + #[allow(unused_imports)] pub use crate::defaults::{ DEFAULT_DEDUP_RETENTION_SECS, DEFAULT_MAX_DEDUP_CACHE_SIZE, DEFAULT_MAX_NOTIFICATION_AGE_SECS, diff --git a/src/nostr/events/processor.rs b/src/nostr/events/processor.rs index 29726d8..19dc010 100644 --- a/src/nostr/events/processor.rs +++ b/src/nostr/events/processor.rs @@ -196,69 +196,6 @@ impl ReplayProtectionConfig { } impl EventProcessor { - /// Create a new event processor with default settings. - /// - /// Uses default cache sizes and rate limits. For production use, - /// prefer `with_full_config` to specify all parameters explicitly. - #[cfg(test)] - pub fn new( - nip59_handler: Nip59Handler, - token_decryptor: TokenDecryptor, - push_dispatcher: Arc, - ) -> Self { - Self::with_full_config( - nip59_handler, - token_decryptor, - push_dispatcher, - DEFAULT_MAX_DEDUP_CACHE_SIZE, - TokenRateLimitConfig::default(), - Metrics::disabled(), - ) - } - - /// Create a new event processor with a custom dedup cache size. - #[cfg(test)] - pub fn with_cache_size( - nip59_handler: Nip59Handler, - token_decryptor: TokenDecryptor, - push_dispatcher: Arc, - max_cache_size: usize, - ) -> Self { - Self::with_full_config( - nip59_handler, - token_decryptor, - push_dispatcher, - max_cache_size, - TokenRateLimitConfig::default(), - Metrics::disabled(), - ) - } - - /// Create a new event processor with full configuration. - #[cfg(test)] - pub fn with_full_config( - nip59_handler: Nip59Handler, - token_decryptor: TokenDecryptor, - push_dispatcher: Arc, - max_dedup_cache_size: usize, - rate_limit_config: TokenRateLimitConfig, - metrics: Metrics, - ) -> Self { - Self::with_replay_config( - nip59_handler, - token_decryptor, - push_dispatcher, - rate_limit_config, - ReplayProtectionConfig { - max_dedup_cache_size: NonZeroUsize::new(max_dedup_cache_size) - .expect("test dedup cache size must be non-zero"), - ..ReplayProtectionConfig::default() - }, - metrics, - ) - .expect("in-memory replay protection config cannot fail") - } - /// Create a new event processor with full replay-protection configuration. pub fn with_replay_config( nip59_handler: Nip59Handler, @@ -963,6 +900,71 @@ impl EventProcessor { .unwrap_or(0) } } + +/// Test-only fluent builder for [`EventProcessor`]. +/// +/// Production code should use [`EventProcessor::with_replay_config`] directly. +#[cfg(test)] +pub(crate) struct EventProcessorBuilder { + nip59_handler: Nip59Handler, + token_decryptor: TokenDecryptor, + push_dispatcher: Arc, + rate_limit_config: TokenRateLimitConfig, + replay_config: ReplayProtectionConfig, + metrics: Metrics, +} + +#[cfg(test)] +impl EventProcessorBuilder { + pub(crate) fn new( + nip59_handler: Nip59Handler, + token_decryptor: TokenDecryptor, + push_dispatcher: Arc, + ) -> Self { + Self { + nip59_handler, + token_decryptor, + push_dispatcher, + rate_limit_config: TokenRateLimitConfig::default(), + replay_config: ReplayProtectionConfig::default(), + metrics: Metrics::disabled(), + } + } + + pub(crate) fn max_dedup_cache_size(mut self, size: usize) -> Self { + self.replay_config.max_dedup_cache_size = + NonZeroUsize::new(size).expect("dedup cache size must be non-zero"); + self + } + + pub(crate) fn rate_limit_config(mut self, config: TokenRateLimitConfig) -> Self { + self.rate_limit_config = config; + self + } + + pub(crate) fn replay_config(mut self, config: ReplayProtectionConfig) -> Self { + self.replay_config = config; + self + } + + pub(crate) fn metrics(mut self, metrics: Metrics) -> Self { + self.metrics = metrics; + self + } + + pub(crate) fn build(self) -> EventProcessor { + EventProcessor::with_replay_config( + self.nip59_handler, + self.token_decryptor, + self.push_dispatcher, + self.rate_limit_config, + self.replay_config, + self.metrics, + ) + .expect("test EventProcessorBuilder config cannot fail") + } +} + #[cfg(test)] mod tests { use super::*; @@ -1063,7 +1065,7 @@ mod tests { None, Metrics::disabled(), )); - EventProcessor::new(nip59_handler, token_decryptor, push_dispatcher) + EventProcessorBuilder::new(nip59_handler, token_decryptor, push_dispatcher).build() } fn create_processor_with_cache_size(server_keys: &Keys, cache_size: usize) -> EventProcessor { @@ -1077,7 +1079,9 @@ mod tests { None, Metrics::disabled(), )); - EventProcessor::with_cache_size(nip59_handler, token_decryptor, push_dispatcher, cache_size) + EventProcessorBuilder::new(nip59_handler, token_decryptor, push_dispatcher) + .max_dedup_cache_size(cache_size) + .build() } fn create_processor_with_replay_config( @@ -1094,15 +1098,9 @@ mod tests { None, Metrics::disabled(), )); - EventProcessor::with_replay_config( - nip59_handler, - token_decryptor, - push_dispatcher, - TokenRateLimitConfig::default(), - replay_config, - Metrics::disabled(), - ) - .expect("replay-protected processor") + EventProcessorBuilder::new(nip59_handler, token_decryptor, push_dispatcher) + .replay_config(replay_config) + .build() } fn create_processor_with_metrics(server_keys: &Keys) -> (EventProcessor, Metrics) { @@ -1134,14 +1132,10 @@ mod tests { metrics.clone(), )); ( - EventProcessor::with_full_config( - nip59_handler, - token_decryptor, - push_dispatcher, - DEFAULT_MAX_DEDUP_CACHE_SIZE, - rate_limit_config, - metrics.clone(), - ), + EventProcessorBuilder::new(nip59_handler, token_decryptor, push_dispatcher) + .rate_limit_config(rate_limit_config) + .metrics(metrics.clone()) + .build(), metrics, ) } @@ -1183,14 +1177,10 @@ mod tests { push_dispatcher.wait_for_completion().await; ( - EventProcessor::with_full_config( - nip59_handler, - token_decryptor, - push_dispatcher, - DEFAULT_MAX_DEDUP_CACHE_SIZE, - rate_limit_config, - metrics.clone(), - ), + EventProcessorBuilder::new(nip59_handler, token_decryptor, push_dispatcher) + .rate_limit_config(rate_limit_config) + .metrics(metrics.clone()) + .build(), metrics, ) } @@ -2184,7 +2174,7 @@ mod tests { } #[tokio::test] - #[should_panic(expected = "test dedup cache size must be non-zero")] + #[should_panic(expected = "dedup cache size must be non-zero")] async fn test_cache_size_zero_is_rejected() { // A zero dedup cache size is no longer silently swapped for the default: // `ReplayProtectionConfig::max_dedup_cache_size` is `NonZeroUsize`, so a @@ -2388,14 +2378,9 @@ mod tests { None, Metrics::disabled(), )); - EventProcessor::with_full_config( - nip59_handler, - token_decryptor, - push_dispatcher, - DEFAULT_MAX_DEDUP_CACHE_SIZE, - rate_limit_config, - Metrics::disabled(), - ) + EventProcessorBuilder::new(nip59_handler, token_decryptor, push_dispatcher) + .rate_limit_config(rate_limit_config) + .build() } #[tokio::test] @@ -2695,14 +2680,9 @@ mod tests { Some(FcmClient::mock(fcm_config, true)), metrics.clone(), )); - let processor = EventProcessor::with_full_config( - nip59_handler, - token_decryptor, - push_dispatcher, - DEFAULT_MAX_DEDUP_CACHE_SIZE, - TokenRateLimitConfig::default(), - metrics.clone(), - ); + let processor = EventProcessorBuilder::new(nip59_handler, token_decryptor, push_dispatcher) + .metrics(metrics.clone()) + .build(); let encryptor = TokenEncryptor::from_keys(&server_keys); let encrypted = encryptor.encrypt(&TestToken::apns( @@ -2780,14 +2760,9 @@ mod tests { )); // Retain a handle to inspect DeliveryHealth after processing. let dispatcher_handle = Arc::clone(&push_dispatcher); - let processor = EventProcessor::with_full_config( - nip59_handler, - token_decryptor, - push_dispatcher, - DEFAULT_MAX_DEDUP_CACHE_SIZE, - TokenRateLimitConfig::default(), - metrics.clone(), - ); + let processor = EventProcessorBuilder::new(nip59_handler, token_decryptor, push_dispatcher) + .metrics(metrics.clone()) + .build(); // Seed a real hard-failure streak on FCM just below the flagging // threshold (a genuine prior outage), so we can prove a pre-filtered APNs @@ -2874,14 +2849,9 @@ mod tests { Some(FcmClient::mock(fcm_config, true)), metrics.clone(), )); - let processor = EventProcessor::with_full_config( - nip59_handler, - token_decryptor, - push_dispatcher, - DEFAULT_MAX_DEDUP_CACHE_SIZE, - TokenRateLimitConfig::default(), - metrics.clone(), - ); + let processor = EventProcessorBuilder::new(nip59_handler, token_decryptor, push_dispatcher) + .metrics(metrics.clone()) + .build(); // Build an FCM token whose device bytes are not valid UTF-8. let encryptor = TokenEncryptor::from_keys(&server_keys); From c807325c19308f313ea8442a593f28af2fab00a5 Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:27:12 +0100 Subject: [PATCH 08/10] fix: satisfy CI RUSTFLAGS and rustdoc checks after lib extraction. Remove stale test imports, add RateLimiter::is_empty for clippy, and replace intra-doc links to private items that break once the crate is a library. Co-authored-by: Cursor --- src/app.rs | 1 - src/nostr/client.rs | 2 +- src/nostr/events/admission.rs | 2 +- src/nostr/events/processor.rs | 8 +++----- src/push/auth.rs | 6 +++--- src/push/dispatcher/inflight.rs | 3 ++- src/push/retry.rs | 2 +- src/rate_limiter.rs | 10 ++++++++-- src/telemetry.rs | 4 ++-- 9 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/app.rs b/src/app.rs index a7d37e6..4d907c7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -855,7 +855,6 @@ mod tests { use crate::test_support::{default_server_config, server_config_with}; use axum::{Router, http::StatusCode, routing::get}; use std::io::Write; - use std::path::PathBuf; use tempfile::NamedTempFile; use tokio::net::TcpListener; diff --git a/src/nostr/client.rs b/src/nostr/client.rs index d7ec820..3ba1046 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -353,7 +353,7 @@ impl RelayClient { /// This is the only place the cached [`RelayStatus`] and the /// `transponder_relays_connected` gauges are written: it enumerates the /// relay pool, classifies each connected relay by the configured list it - /// came from (see [`classify_relay_kind`]), and stores the result. It is + /// came from (see `classify_relay_kind`), and stores the result. It is /// driven by `connect()` while polling for the first connection and by /// the periodic status-refresher task in `main` — never by the read paths /// ([`Self::get_status`]/[`Self::is_connected`]), so readiness probes are diff --git a/src/nostr/events/admission.rs b/src/nostr/events/admission.rs index 4e8b988..a973ff5 100644 --- a/src/nostr/events/admission.rs +++ b/src/nostr/events/admission.rs @@ -36,7 +36,7 @@ impl StageTimer { } } -/// Outcome of [`EventProcessor::process_inner`]. +/// Outcome of the event processor's inner processing path. /// /// Distinguishes a terminal result (notifications admitted, or the event /// genuinely carried nothing dispatchable) from a purely transient per-token diff --git a/src/nostr/events/processor.rs b/src/nostr/events/processor.rs index 19dc010..7d0ca35 100644 --- a/src/nostr/events/processor.rs +++ b/src/nostr/events/processor.rs @@ -972,7 +972,6 @@ mod tests { use crate::crypto::Platform; use crate::crypto::token::ENCRYPTED_TOKEN_SIZE; use crate::defaults::{ - DEFAULT_DEDUP_RETENTION_SECS, DEFAULT_MAX_DEDUP_CACHE_SIZE, DEFAULT_MAX_NOTIFICATION_AGE_SECS, DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS, DEFAULT_MAX_TOKENS_PER_EVENT, }; @@ -985,7 +984,6 @@ mod tests { }; use crate::test_support::{default_server_config, server_config_with}; use crate::test_vectors::scenarios; - use zeroize::Zeroizing; #[test] fn token_rate_limit_config_from_server_config_matches_settings() { @@ -1963,7 +1961,7 @@ mod tests { .await, Some((0, 0)) ); - assert_eq!(processor.device_token_limiter.len().await, 0); + assert!(processor.device_token_limiter.is_empty().await); assert_eq!( counter_value(&metrics, "transponder_events_processed_total", &[]), 0.0 @@ -2710,7 +2708,7 @@ mod tests { "unconfigured-platform drop must refund the encrypted charge" ); // The device limiter was never charged (drop happens before it). - assert_eq!(processor.device_token_limiter.len().await, 0); + assert!(processor.device_token_limiter.is_empty().await); // The drop is recorded by a real metric. assert_eq!( counter_value( @@ -2879,7 +2877,7 @@ mod tests { None, "non-UTF-8 FCM drop must refund the encrypted charge" ); - assert_eq!(processor.device_token_limiter.len().await, 0); + assert!(processor.device_token_limiter.is_empty().await); assert_eq!( counter_value( &metrics, diff --git a/src/push/auth.rs b/src/push/auth.rs index c9703a3..af2f54f 100644 --- a/src/push/auth.rs +++ b/src/push/auth.rs @@ -6,12 +6,12 @@ //! owns the caching/invalidation semantics once so the two clients cannot //! drift (issue #158): //! -//! - [`AuthTokenGenerator`] is the provider-specific mint step. -//! - [`TokenCache`] wraps a generator with a read-lock fast path and a +//! - `AuthTokenGenerator` is the provider-specific mint step. +//! - `TokenCache` wraps a generator with a read-lock fast path and a //! single-flight refresh that runs *outside* the cache write lock, so //! readers keep serving a still-valid token while a refresh is in flight //! (issue #86) and concurrent misses coalesce into a single mint. -//! - [`TokenCache::invalidate_if_matches`] evicts a provider-rejected +//! - `TokenCache::invalidate_if_matches` evicts a provider-rejected //! credential only while it is still the cached one. *Whether* a rejection //! is credential-related is decided by the caller: APNs must parse the 403 //! `reason` first and evict only for provider-token reasons (issue #145), diff --git a/src/push/dispatcher/inflight.rs b/src/push/dispatcher/inflight.rs index a13657d..badc672 100644 --- a/src/push/dispatcher/inflight.rs +++ b/src/push/dispatcher/inflight.rs @@ -8,7 +8,8 @@ use tokio::sync::Notify; /// Tracks the number of spawned send tasks that have not yet finished, so that /// graceful shutdown can wait for them to complete. /// -/// This is deliberately decoupled from the concurrency [`Semaphore`]: a send +/// This is deliberately decoupled from the concurrency +/// [`tokio::sync::Semaphore`]: a send /// task that is in a retry backoff sleep releases its concurrency permit (see /// [`crate::push::retry::BackoffPermit`]) so the slot can be reused, but the /// task is still alive and may re-acquire a permit and send again. Counting diff --git a/src/push/retry.rs b/src/push/retry.rs index 22e79f3..7996ec5 100644 --- a/src/push/retry.rs +++ b/src/push/retry.rs @@ -328,7 +328,7 @@ fn should_retry_transport(err: &reqwest::Error) -> bool { /// Execute an async transport operation with exponential backoff retry. /// /// Only `Error::Http` failures whose underlying `reqwest::Error` is a -/// connection-establishment error (see [`should_retry_transport`]) are +/// connection-establishment error (see `should_retry_transport`) are /// retried. Restricting retries to pre-delivery connect errors avoids /// duplicating non-idempotent POSTs (a read/timeout error can fire *after* /// the provider already accepted the request) and bounds worst-case permit-hold diff --git a/src/rate_limiter.rs b/src/rate_limiter.rs index 02f3054..2e823ad 100644 --- a/src/rate_limiter.rs +++ b/src/rate_limiter.rs @@ -120,7 +120,7 @@ impl RateLimitCheck { /// Returns a sampled total-entry count for opportunistic gauge updates. /// /// `Some(len)` on the sampled fraction of admissions (see - /// [`GAUGE_SAMPLE_INTERVAL`]); `None` otherwise. Lets the caller keep the + /// `GAUGE_SAMPLE_INTERVAL`); `None` otherwise. Lets the caller keep the /// `transponder_rate_limit_cache_size` gauge fresh as the cache grows /// toward capacity without a metric write per check. #[must_use] @@ -555,6 +555,12 @@ impl RateLimiter { self.total_len().await } + /// Returns whether the rate limiter has no entries. + #[cfg(test)] + pub async fn is_empty(&self) -> bool { + self.len().await == 0 + } + /// Checks the current count without incrementing. #[cfg(test)] pub async fn peek_counts(&self, key: &K) -> Option<(u32, u32)> { @@ -630,7 +636,7 @@ mod tests { limiter.rollback_increment(&1u64, reservation).await; - assert_eq!(limiter.len().await, 0); + assert!(limiter.is_empty().await); assert!(limiter.check_and_increment(&1u64).await.is_allowed()); } diff --git a/src/telemetry.rs b/src/telemetry.rs index 2f3febb..0d73bd4 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -1,12 +1,12 @@ //! Error and panic reporting to a GlitchTip (Sentry-compatible) instance. //! //! Reporting is opt-in: it activates only when a DSN is configured. Transponder's -//! own `ERROR` events are forwarded — first-party only, see [`glitchtip_layer`] — +//! own `ERROR` events are forwarded — first-party only, see `glitchtip_layer` — //! together with panics, which Sentry's global hook captures from anywhere in the //! process (not just first-party code). First-party message content is kept clean //! by the "never log secrets" invariant in AGENTS.md; because the panic hook is //! not target-scoped, every outgoing event additionally passes through the -//! mechanical redaction backstop in [`scrub_event`] (see [`crate::redaction`]). +//! mechanical redaction backstop in `scrub_event` (see [`crate::redaction`]). use std::sync::Arc; From cbc718fd47805af99b3bde7b3be416e48fbfba0f Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:36:58 +0100 Subject: [PATCH 09/10] test: restore coverage after architecture refactor. Add focused tests for defaults, admission refunds, RateLimiter::is_empty, and clearnet startup validation so CI coverage does not drop below master. Co-authored-by: Cursor --- src/app.rs | 14 ++++++++++ src/defaults.rs | 13 +++++++++ src/nostr/events/admission.rs | 51 +++++++++++++++++++++++++++++++++++ src/rate_limiter.rs | 13 +++++++++ 4 files changed, 91 insertions(+) diff --git a/src/app.rs b/src/app.rs index 4d907c7..ab24234 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1550,6 +1550,20 @@ mod tests { ); } + #[test] + fn validate_startup_config_accepts_clearnet_relays() { + let relays = crate::config::RelayConfig { + clearnet: vec!["wss://relay.example.com".to_string()], + allow_unencrypted_clearnet_relays: false, + onion: Vec::new(), + reconnect_interval_secs: 5, + max_reconnect_attempts: 10, + connection_timeout_secs: 30, + }; + + assert!(validate_startup_config("abc123", &relays).is_ok()); + } + #[test] fn validate_startup_config_rejects_missing_private_key() { let relays = crate::config::RelayConfig { diff --git a/src/defaults.rs b/src/defaults.rs index ad87d94..603a9bb 100644 --- a/src/defaults.rs +++ b/src/defaults.rs @@ -45,3 +45,16 @@ pub const DEFAULT_MAX_TOKENS_PER_EVENT: usize = 100; /// Default maximum number of events processed concurrently. pub const DEFAULT_MAX_CONCURRENT_EVENT_PROCESSING: usize = 64; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedup_retention_matches_nip59_window_plus_skew() { + assert_eq!( + DEFAULT_DEDUP_RETENTION_SECS, + NIP59_TIMESTAMP_TWEAK_WINDOW_SECS + DEFAULT_MAX_NOTIFICATION_FUTURE_SKEW_SECS + ); + } +} diff --git a/src/nostr/events/admission.rs b/src/nostr/events/admission.rs index a973ff5..b974c2c 100644 --- a/src/nostr/events/admission.rs +++ b/src/nostr/events/admission.rs @@ -180,3 +180,54 @@ pub(crate) fn platform_metric_label(platform: Platform) -> &'static str { Platform::Fcm => "fcm", } } + +#[cfg(test)] +mod tests { + use std::num::NonZeroUsize; + + use super::*; + use crate::rate_limiter::{RateLimitConfig, RateLimiter}; + + fn entries(n: usize) -> NonZeroUsize { + NonZeroUsize::new(n).expect("non-zero") + } + + #[tokio::test] + async fn refund_rolls_back_encrypted_and_device_charges() { + let encrypted_limiter: RateLimiter<[u8; 32]> = RateLimiter::new(RateLimitConfig { + max_per_minute: 10, + max_per_hour: 100, + max_entries: entries(10), + }); + let device_limiter: RateLimiter<[u8; 32]> = RateLimiter::new(RateLimitConfig { + max_per_minute: 10, + max_per_hour: 100, + max_entries: entries(10), + }); + + let encrypted_key = [1u8; 32]; + let device_key = [2u8; 32]; + let encrypted_reservation = encrypted_limiter + .check_and_increment(&encrypted_key) + .await + .reservation() + .expect("encrypted admit"); + let device_reservation = device_limiter + .check_and_increment(&device_key) + .await + .reservation() + .expect("device admit"); + + let mut guard = AdmissionGuard::new( + &encrypted_limiter, + &device_limiter, + encrypted_key, + encrypted_reservation, + ); + guard.add_device_charge(device_key, device_reservation); + guard.refund().await; + + assert!(encrypted_limiter.is_empty().await); + assert!(device_limiter.is_empty().await); + } +} diff --git a/src/rate_limiter.rs b/src/rate_limiter.rs index 2e823ad..d0b4c28 100644 --- a/src/rate_limiter.rs +++ b/src/rate_limiter.rs @@ -640,6 +640,19 @@ mod tests { assert!(limiter.check_and_increment(&1u64).await.is_allowed()); } + #[tokio::test] + async fn test_is_empty_reports_false_with_entries() { + let limiter: RateLimiter = RateLimiter::new(RateLimitConfig { + max_per_minute: 10, + max_per_hour: 100, + max_entries: entries(100), + }); + + assert!(limiter.is_empty().await); + limiter.check_and_increment(&1u64).await; + assert!(!limiter.is_empty().await); + } + #[tokio::test] async fn test_rollback_increment_removes_only_reserved_hit() { let limiter: RateLimiter = RateLimiter::new(RateLimitConfig { From d41dc79102172adb4686cf315dcd81041f00f649 Mon Sep 17 00:00:00 2001 From: Jeff Gardner <202880+erskingardner@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:04:41 +0100 Subject: [PATCH 10/10] Address PR review: metrics guards, init failure, test fixtures. Propagate metrics registry init failures instead of retrying via Metrics::disabled(), gate notification-parse and dispatch-admission observers when disabled, and add tests for those paths and test_support. Co-authored-by: Cursor --- src/app.rs | 29 +++++++++++++---------------- src/metrics.rs | 30 ++++++++++++++++++++++++++++++ src/test_support.rs | 23 +++++++++++++++++++++++ 3 files changed, 66 insertions(+), 16 deletions(-) diff --git a/src/app.rs b/src/app.rs index ab24234..cb9d4a4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -336,22 +336,19 @@ pub async fn run(mut config: AppConfig) -> Result<()> { // landed there, so no separate load-time warning is needed here. // Initialize metrics (always present; recording gated by `enabled`). - let metrics = match Metrics::new() { - Ok(metrics) => { - let metrics = metrics.with_enabled(config.metrics.enabled); - if metrics.is_enabled() { - metrics.init_server_info(env!("CARGO_PKG_VERSION")); - info!("Metrics initialized"); - } else { - info!("Metrics disabled"); - } - metrics - } - Err(e) => { - error!(error = %e, "Failed to initialize metrics"); - Metrics::disabled() - } - }; + let metrics = Metrics::new() + .map(|metrics| metrics.with_enabled(config.metrics.enabled)) + .map_err(|error| { + error!(error = %error, "Failed to initialize metrics"); + error + }) + .context("Failed to initialize metrics")?; + if metrics.is_enabled() { + metrics.init_server_info(env!("CARGO_PKG_VERSION")); + info!("Metrics initialized"); + } else { + info!("Metrics disabled"); + } let server_private_key = resolve_server_private_key(&mut config.server)?; diff --git a/src/metrics.rs b/src/metrics.rs index 9e740d4..250b0ed 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -773,6 +773,9 @@ impl Metrics { outcome: OperationOutcome, duration_secs: f64, ) { + if !self.enabled { + return; + } observe_label_value( &self.notification_parse_duration_seconds, outcome.as_str(), @@ -971,6 +974,9 @@ impl Metrics { outcome: OperationOutcome, duration_secs: f64, ) { + if !self.enabled { + return; + } observe_label_value( &self.push_dispatch_admission_duration_seconds, outcome.as_str(), @@ -1087,6 +1093,30 @@ mod tests { assert!(!metrics.registry.gather().is_empty()); } + #[test] + fn test_disabled_metrics_observers_are_no_ops() { + let metrics = Metrics::new().unwrap().with_enabled(false); + assert!(!metrics.is_enabled()); + + metrics.observe_notification_parse_duration(OperationOutcome::Success, 0.001); + metrics.observe_push_dispatch_admission_duration(OperationOutcome::Success, 0.001); + + assert_eq!( + metrics + .notification_parse_duration_seconds + .with_label_values(&[OperationOutcome::Success.as_str()]) + .get_sample_count(), + 0 + ); + assert_eq!( + metrics + .push_dispatch_admission_duration_seconds + .with_label_values(&[OperationOutcome::Success.as_str()]) + .get_sample_count(), + 0 + ); + } + #[test] fn test_event_metrics() { let metrics = Metrics::new().unwrap(); diff --git a/src/test_support.rs b/src/test_support.rs index 86129a1..a086608 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -45,3 +45,26 @@ pub fn server_config_with( f(&mut config); config } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_server_config_has_no_zeroed_limits() { + let config = default_server_config(); + assert_ne!(config.max_dedup_cache_size, 0); + assert_ne!(config.max_rate_limit_cache_size, 0); + assert_ne!(config.max_tokens_per_event, 0); + assert_ne!(config.shutdown_timeout_secs, 0); + assert_ne!(config.max_concurrent_event_processing, 0); + } + + #[test] + fn server_config_with_applies_override() { + let config = server_config_with(default_server_config(), |c| { + c.max_tokens_per_event = 42; + }); + assert_eq!(config.max_tokens_per_event, 42); + } +}