//! Runtime-neutral client configuration, composition, and lifecycle ownership. use crate::{ AgentSettings, AssetCacheSettings, ConnectionSettings, LoggingSettings, PacketSettings, ParcelSettings, TexturePipelineSettings, TimingSettings, WorldSettings, }; use libremetaverse_types::compat::{CancellationToken, CancellationTokenSource, TimeProvider}; use std::fmt; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, Condvar, Mutex}; /// A validated client-core failure with no credential or capability payload. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum ClientCoreError { /// A public settings field failed validation. InvalidConfiguration { field: &'static str, reason: &'static str, }, /// A lifecycle operation was requested in an incompatible state. InvalidLifecycle { operation: &'static str, state: ClientLifecycleState, }, /// An owned service could not complete orderly shutdown. ServiceShutdown { service: &'static str }, } impl fmt::Display for ClientCoreError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidConfiguration { field, reason } => { write!(formatter, "invalid client setting {field}: {reason}") } Self::InvalidLifecycle { operation, state } => { write!(formatter, "cannot {operation} while client is {state:?}") } Self::ServiceShutdown { service } => { write!(formatter, "client service {service} failed to shut down") } } } } impl std::error::Error for ClientCoreError {} impl From for crate::Error { fn from(error: ClientCoreError) -> Self { match error { ClientCoreError::InvalidConfiguration { .. } => Self::Argument, ClientCoreError::InvalidLifecycle { .. } | ClientCoreError::ServiceShutdown { .. } => { Self::InvalidOperation } } } } /// Observable lifecycle of a [`GridClient`]. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[repr(u8)] pub enum ClientLifecycleState { Active, ShuttingDown, Disposed, } impl ClientLifecycleState { fn from_u8(value: u8) -> Self { match value { 0 => Self::Active, 1 => Self::ShuttingDown, _ => Self::Disposed, } } } /// Defines the C#-compatible shutdown order for explicitly composed services. #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] #[repr(u8)] pub enum ShutdownPhase { Network = 0, Manager = 1, Http = 2, RateLimiter = 3, } /// A client-owned subsystem that participates in deterministic teardown. /// /// Implementations own their tasks and I/O resources. They must use the client /// cancellation token and must not require `GridClient` to create an executor. pub trait ClientService: Send + Sync { /// Stable diagnostic name. It must not contain credentials or capability URLs. fn name(&self) -> &'static str; fn shutdown_phase(&self) -> ShutdownPhase { ShutdownPhase::Manager } /// Cancels and joins resources owned by this service. /// /// # Errors /// /// Returns a redacted service error when orderly teardown cannot complete. fn shutdown(&self) -> Result<(), ClientCoreError>; } struct ServiceRegistration { insertion_order: usize, service: Arc, } struct ClientRuntime { state: AtomicU8, cancellation: CancellationTokenSource, services: Mutex>, agent_throttle_sender: Mutex>>, http_caps_client: Mutex, caps_rate_limiter: Mutex, shutdown_complete: Condvar, shutdown_wait: Mutex<()>, } impl ClientRuntime { fn new( services: Vec>, http_caps_client: crate::caps_http::HttpCapsClient, caps_rate_limiter: crate::caps_http::CapsRateLimiter, ) -> Self { Self { state: AtomicU8::new(ClientLifecycleState::Active as u8), cancellation: CancellationTokenSource::new(), services: Mutex::new( services .into_iter() .enumerate() .map(|(insertion_order, service)| ServiceRegistration { insertion_order, service, }) .collect(), ), agent_throttle_sender: Mutex::new(None), http_caps_client: Mutex::new(http_caps_client), caps_rate_limiter: Mutex::new(caps_rate_limiter), shutdown_complete: Condvar::new(), shutdown_wait: Mutex::new(()), } } fn state(&self) -> ClientLifecycleState { ClientLifecycleState::from_u8(self.state.load(Ordering::Acquire)) } fn register(&self, service: Arc) -> Result<(), ClientCoreError> { let mut services = self .services .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let state = self.state(); if state != ClientLifecycleState::Active { return Err(ClientCoreError::InvalidLifecycle { operation: "register a service", state, }); } let insertion_order = services.len(); services.push(ServiceRegistration { insertion_order, service, }); Ok(()) } fn shutdown(&self) -> Result<(), ClientCoreError> { if self .state .compare_exchange( ClientLifecycleState::Active as u8, ClientLifecycleState::ShuttingDown as u8, Ordering::AcqRel, Ordering::Acquire, ) .is_err() { let mut wait = self .shutdown_wait .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); while self.state() == ClientLifecycleState::ShuttingDown { wait = self .shutdown_complete .wait(wait) .unwrap_or_else(std::sync::PoisonError::into_inner); } return Ok(()); } self.cancellation.cancel(); let mut services = self .services .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); services.sort_by_key(|entry| (entry.service.shutdown_phase(), entry.insertion_order)); let mut first_error = None; for entry in services.iter() { if let Err(error) = entry.service.shutdown() && first_error.is_none() { first_error = Some(error); } } services.clear(); self.agent_throttle_sender .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .take(); self.http_caps_client .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .shutdown(); let _ = self .caps_rate_limiter .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .dispose(); let shutdown_wait = self .shutdown_wait .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); self.state .store(ClientLifecycleState::Disposed as u8, Ordering::Release); self.shutdown_complete.notify_all(); drop(shutdown_wait); first_error.map_or(Ok(()), Err) } } /// Native owner for the mapped C# `GridClient` lifecycle slice. /// /// Construction is side-effect free: it creates no runtime, task, socket, or /// HTTP request. Network-facing services are attached explicitly and are owned /// until ordered shutdown or drop. #[derive(Clone)] pub struct GridClient { pub(crate) settings: Settings, pub(crate) time_provider: TimeProvider, runtime: Arc, } #[cfg(test)] pub(crate) struct ClientRetentionProbe(std::sync::Weak); #[cfg(test)] impl ClientRetentionProbe { pub(crate) fn is_released(&self) -> bool { self.0.upgrade().is_none() } } impl GridClient { #[must_use] pub fn builder() -> GridClientBuilder { GridClientBuilder::default() } /// Returns a shared settings view without requiring mutable client access. #[must_use] pub fn settings_ref(&self) -> &Settings { &self.settings } #[must_use] pub fn cancellation_token(&self) -> CancellationToken { self.runtime.cancellation.token() } #[must_use] pub fn lifecycle_state(&self) -> ClientLifecycleState { self.runtime.state() } /// Adds an explicitly constructed service to this client's ownership tree. /// /// # Errors /// /// Registration fails after shutdown begins. pub fn register_service( &mut self, service: Arc, ) -> Result<(), ClientCoreError> { self.runtime.register(service) } /// Installs the network-owned callback used by mapped `AgentThrottle.Set`. /// /// # Errors /// /// Registration fails after client shutdown begins. pub fn set_agent_throttle_sender( &mut self, sender: Arc, ) -> Result<(), ClientCoreError> { let mut registered = self .runtime .agent_throttle_sender .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let state = self.lifecycle_state(); if state != ClientLifecycleState::Active { return Err(ClientCoreError::InvalidLifecycle { operation: "register the agent throttle sender", state, }); } *registered = Some(sender); Ok(()) } pub(crate) fn agent_throttle_sender( &self, ) -> Option> { self.runtime .agent_throttle_sender .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone() } pub(crate) fn native_http_caps_client(&self) -> crate::caps_http::HttpCapsClient { self.runtime .http_caps_client .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone() } pub(crate) fn native_set_http_caps_client(&mut self, value: crate::caps_http::HttpCapsClient) { *self .runtime .http_caps_client .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = value; } pub(crate) fn native_caps_rate_limiter(&self) -> crate::caps_http::CapsRateLimiter { self.runtime .caps_rate_limiter .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone() } pub(crate) fn native_set_caps_rate_limiter( &mut self, value: crate::caps_http::CapsRateLimiter, ) { self.native_http_caps_client() .set_rate_limiter(Some(value.clone())); *self .runtime .caps_rate_limiter .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) = value; } pub(crate) fn shutdown(&self) -> Result<(), ClientCoreError> { self.runtime.shutdown() } #[cfg(test)] pub(crate) fn retention_probe(&self) -> ClientRetentionProbe { ClientRetentionProbe(Arc::downgrade(&self.runtime)) } } impl fmt::Debug for GridClient { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { let service_names: Vec<_> = self .runtime .services .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .iter() .map(|entry| entry.service.name()) .collect(); formatter .debug_struct("GridClient") .field("settings", &self.settings) .field("lifecycle_state", &self.lifecycle_state()) .field("services", &service_names) .field("time_provider", &self.time_provider) .finish() } } impl Drop for GridClient { fn drop(&mut self) { if Arc::strong_count(&self.runtime) == 1 { let _ = self.runtime.shutdown(); } } } impl crate::IGridClient for GridClient { fn http_caps_client(&self) -> crate::caps_http::HttpCapsClient { self.native_http_caps_client() } fn settings(&self) -> Settings { self.settings.clone() } fn time_provider(&self) -> TimeProvider { self.time_provider.clone() } fn set_time_provider(&mut self, value: TimeProvider) { self.time_provider = value; } } /// Explicit, runtime-neutral composition entry point for [`GridClient`]. pub struct GridClientBuilder { settings: Settings, time_provider: TimeProvider, services: Vec>, } impl Default for GridClientBuilder { fn default() -> Self { Self { settings: Settings::defaults(), time_provider: TimeProvider::system(), services: Vec::new(), } } } impl GridClientBuilder { #[must_use] pub fn with_settings(mut self, settings: Settings) -> Self { self.settings = settings; self } #[must_use] pub fn with_time_provider(mut self, time_provider: TimeProvider) -> Self { self.time_provider = time_provider; self } #[must_use] pub fn with_service(mut self, service: Arc) -> Self { self.services.push(service); self } /// Validates configuration and creates a client without starting I/O. /// /// # Errors /// /// Returns the first invalid configuration field. pub fn build(self) -> Result { self.settings.validate()?; let caps_rate_limiter = crate::caps_http::CapsRateLimiter::new_with_clock_and_overrides( self.time_provider.clone(), None, ) .map_err(|_| ClientCoreError::InvalidConfiguration { field: "CapsRateLimiter", reason: "could not construct the configured token buckets", })?; let max_connections = usize::try_from(Settings::max_http_connections()).map_err(|_| { ClientCoreError::InvalidConfiguration { field: "Settings.MaxHttpConnections", reason: "must be positive", } })?; let caps_timeout = u64::try_from(self.settings.timing.caps_timeout).map_err(|_| { ClientCoreError::InvalidConfiguration { field: "Timing.CapsTimeout", reason: "must be positive", } })?; let http_caps_client = crate::caps_http::HttpCapsClient::production( &Settings::user_agent(), std::time::Duration::from_millis(caps_timeout), max_connections, caps_rate_limiter.clone(), ) .map_err(|_| ClientCoreError::InvalidConfiguration { field: "HttpCapsClient", reason: "could not construct the HTTP transport", })?; Ok(GridClient { settings: self.settings, time_provider: self.time_provider, runtime: Arc::new(ClientRuntime::new( self.services, http_caps_client, caps_rate_limiter, )), }) } } /// Native storage for the mapped grouped settings object. #[derive(Clone, PartialEq)] pub struct Settings { pub default_effect_color: libremetaverse_types::Color4, pub(crate) connection: ConnectionSettings, pub(crate) timing: TimingSettings, pub(crate) packets: PacketSettings, pub(crate) agent: AgentSettings, pub(crate) world: WorldSettings, pub(crate) parcel: ParcelSettings, pub(crate) asset_cache: AssetCacheSettings, pub(crate) texture_pipeline: TexturePipelineSettings, pub(crate) logging: LoggingSettings, pub(crate) upload_cost: i32, } impl Settings { pub(crate) fn defaults() -> Self { Self { default_effect_color: libremetaverse_types::Color4 { r: 1.0, g: 0.0, b: 0.0, a: 1.0, }, connection: connection_settings_defaults(), timing: timing_settings_defaults(), packets: packet_settings_defaults(), agent: agent_settings_defaults(), world: world_settings_defaults(), parcel: parcel_settings_defaults(), asset_cache: asset_cache_settings_defaults(), texture_pipeline: texture_pipeline_settings_defaults(), logging: logging_settings_defaults(), upload_cost: 0, } } /// Mutable access to the mapped agent settings for native composition. pub fn agent_settings_mut(&mut self) -> &mut AgentSettings { &mut self.agent } #[must_use] pub fn connection_mut(&mut self) -> &mut ConnectionSettings { &mut self.connection } #[must_use] pub fn packets_mut(&mut self) -> &mut PacketSettings { &mut self.packets } #[must_use] pub fn agent_mut(&mut self) -> &mut AgentSettings { &mut self.agent } #[must_use] pub fn world_mut(&mut self) -> &mut WorldSettings { &mut self.world } #[must_use] pub fn parcel_mut(&mut self) -> &mut ParcelSettings { &mut self.parcel } #[must_use] pub fn asset_cache_mut(&mut self) -> &mut AssetCacheSettings { &mut self.asset_cache } #[must_use] pub fn texture_pipeline_mut(&mut self) -> &mut TexturePipelineSettings { &mut self.texture_pipeline } #[must_use] pub fn logging_mut(&mut self) -> &mut LoggingSettings { &mut self.logging } /// Validates all durations, limits, endpoints, cache, and feature policies. /// /// # Errors /// /// Returns the first field that cannot be used safely by the native client. pub fn validate(&self) -> Result<(), ClientCoreError> { validate_endpoint("Connection.LoginServer", &self.connection.login_server)?; for (field, value) in [ ("Timing.TransferTimeout", self.timing.transfer_timeout), ("Timing.TeleportTimeout", self.timing.teleport_timeout), ("Timing.LogoutTimeout", self.timing.logout_timeout), ("Timing.CapsTimeout", self.timing.caps_timeout), ("Timing.LoginTimeout", self.timing.login_timeout), ("Timing.ResendTimeout", self.timing.resend_timeout), ("Timing.SimulatorTimeout", self.timing.simulator_timeout), ("Timing.MapRequestTimeout", self.timing.map_request_timeout), ( "Timing.AgentUpdateInterval", self.timing.agent_update_interval, ), ( "Timing.InterpolationInterval", self.timing.interpolation_interval, ), ] { validate_positive(field, value)?; } validate_positive("Packets.MaxPendingAcks", self.packets.max_pending_acks)?; validate_positive("Packets.StatsQueueSize", self.packets.stats_queue_size)?; if self.packets.max_resend_count < 0 { return invalid("Packets.MaxResendCount", "must be zero or greater"); } if self.agent.send_updates_regularly && !self.agent.send_updates { return invalid("Agent.SendUpdatesRegularly", "requires Agent.SendUpdates"); } if (self.parcel.always_request_acl || self.parcel.always_request_dwell) && !self.parcel.track_parcels { return invalid( "Parcel.TrackParcels", "must be enabled for automatic parcel requests", ); } validate_positive( "TexturePipeline.MaxConcurrentDownloads", self.texture_pipeline.max_concurrent_downloads, )?; validate_positive( "TexturePipeline.RequestTimeout", self.texture_pipeline.request_timeout, )?; if self.asset_cache.enabled { if self.asset_cache.dir.trim().is_empty() { return invalid( "AssetCache.Dir", "must not be empty when caching is enabled", ); } if self.asset_cache.max_size <= 0 { return invalid( "AssetCache.MaxSize", "must be positive when caching is enabled", ); } } Ok(()) } } impl Default for Settings { fn default() -> Self { Self::defaults() } } impl fmt::Debug for Settings { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("Settings") .field("default_effect_color", &self.default_effect_color) .field("connection", &self.connection) .field("timing", &self.timing) .field("packets", &self.packets) .field("agent", &self.agent) .field("world", &self.world) .field("parcel", &self.parcel) .field("asset_cache", &self.asset_cache) .field("texture_pipeline", &self.texture_pipeline) .field("logging", &self.logging) .field("upload_cost", &self.upload_cost) .finish() } } impl fmt::Debug for ConnectionSettings { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("ConnectionSettings") .field("login_server", &"") .field("mfa_enabled", &self.mfa_enabled) .finish() } } fn invalid(field: &'static str, reason: &'static str) -> Result { Err(ClientCoreError::InvalidConfiguration { field, reason }) } fn validate_positive(field: &'static str, value: i32) -> Result<(), ClientCoreError> { if value <= 0 { invalid(field, "must be positive") } else { Ok(()) } } fn validate_endpoint(field: &'static str, endpoint: &str) -> Result<(), ClientCoreError> { let endpoint = endpoint.trim(); let remainder = endpoint .strip_prefix("https://") .or_else(|| endpoint.strip_prefix("http://")) .ok_or(ClientCoreError::InvalidConfiguration { field, reason: "must use an HTTP or HTTPS URL", })?; let authority = remainder.split(['/', '?', '#']).next().unwrap_or_default(); if authority.is_empty() || authority.contains('@') || endpoint.chars().any(char::is_whitespace) { return invalid( field, "must contain a host and must not embed credentials or whitespace", ); } Ok(()) } pub(crate) fn agent_settings_defaults() -> AgentSettings { AgentSettings { disable_update_duplicate_check: true, multiple_sims: false, send_appearance: true, send_throttle: true, send_updates: true, send_updates_regularly: true, } } pub(crate) fn asset_cache_settings_defaults() -> AssetCacheSettings { AssetCacheSettings { dir: std::path::Path::new("linden") .join("cache") .to_string_lossy() .into_owned(), enabled: true, max_size: 1_024 * 1_024 * 1_024, } } pub(crate) fn connection_settings_defaults() -> ConnectionSettings { ConnectionSettings { login_server: Settings::AGNI_LOGIN_SERVER.to_owned(), mfa_enabled: false, } } pub(crate) fn logging_settings_defaults() -> LoggingSettings { LoggingSettings { log_disk_cache: true, log_names: true, log_resends: true, } } pub(crate) fn packet_settings_defaults() -> PacketSettings { PacketSettings { enable_sim_stats: true, max_pending_acks: 10, max_resend_count: 3, send_pings: true, stats_queue_size: 5, throttle_outgoing: true, track_utilization: false, } } pub(crate) fn parcel_settings_defaults() -> ParcelSettings { ParcelSettings { always_request_acl: true, always_request_dwell: true, pool_parcel_data: false, track_parcels: true, } } pub(crate) fn texture_pipeline_settings_defaults() -> TexturePipelineSettings { TexturePipelineSettings { enabled: true, max_concurrent_downloads: 4, request_timeout: 45_000, use_http_textures: true, } } pub(crate) fn timing_settings_defaults() -> TimingSettings { TimingSettings { agent_update_interval: 500, caps_timeout: 60_000, interpolation_interval: 250, login_timeout: 60_000, logout_timeout: 5_000, map_request_timeout: 5_000, resend_timeout: 4_000, simulator_timeout: 30_000, teleport_timeout: 40_000, transfer_timeout: 90_000, } } pub(crate) fn world_settings_defaults() -> WorldSettings { WorldSettings { always_decode_objects: true, always_request_objects: true, cache_primitives: false, store_land_patches: false, track_avatars: true, track_objects: true, use_interpolation_timer: true, } } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; struct RecordingService { name: &'static str, phase: ShutdownPhase, order: Arc>>, calls: AtomicUsize, } impl ClientService for RecordingService { fn name(&self) -> &'static str { self.name } fn shutdown_phase(&self) -> ShutdownPhase { self.phase } fn shutdown(&self) -> Result<(), ClientCoreError> { self.calls.fetch_add(1, Ordering::Relaxed); self.order .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .push(self.name); Ok(()) } } #[test] #[allow(clippy::float_cmp)] // Exact representable constants are the compatibility contract. fn defaults_match_reference_and_validate() { let settings = Settings::defaults(); assert_eq!( settings.connection.login_server, Settings::AGNI_LOGIN_SERVER ); assert_eq!(settings.timing.transfer_timeout, 90_000); assert_eq!(settings.timing.teleport_timeout, 40_000); assert_eq!(settings.timing.logout_timeout, 5_000); assert_eq!(settings.timing.caps_timeout, 60_000); assert_eq!(settings.timing.login_timeout, 60_000); assert_eq!(settings.timing.resend_timeout, 4_000); assert_eq!(settings.timing.simulator_timeout, 30_000); assert_eq!(settings.timing.map_request_timeout, 5_000); assert_eq!(settings.timing.agent_update_interval, 500); assert_eq!(settings.timing.interpolation_interval, 250); assert_eq!(settings.packets.max_pending_acks, 10); assert_eq!(settings.packets.stats_queue_size, 5); assert_eq!(settings.packets.max_resend_count, 3); assert!(settings.packets.throttle_outgoing); assert!(settings.packets.enable_sim_stats); assert!(settings.packets.send_pings); assert!(!settings.packets.track_utilization); assert!(settings.agent.send_updates); assert!(settings.agent.send_updates_regularly); assert!(settings.agent.send_appearance); assert!(settings.agent.send_throttle); assert!(settings.agent.disable_update_duplicate_check); assert!(!settings.agent.multiple_sims); assert!(settings.world.always_decode_objects); assert!(settings.world.always_request_objects); assert!(settings.world.track_objects); assert!(settings.world.track_avatars); assert!(!settings.world.cache_primitives); assert!(settings.world.use_interpolation_timer); assert!(!settings.world.store_land_patches); assert!(settings.parcel.track_parcels); assert!(settings.parcel.always_request_acl); assert!(settings.parcel.always_request_dwell); assert!(!settings.parcel.pool_parcel_data); assert!(settings.asset_cache.enabled); assert!(settings.asset_cache.dir.ends_with("cache")); assert_eq!(settings.texture_pipeline.max_concurrent_downloads, 4); assert!(settings.texture_pipeline.enabled); assert!(settings.texture_pipeline.use_http_textures); assert_eq!(settings.texture_pipeline.request_timeout, 45_000); assert!(settings.logging.log_names); assert!(settings.logging.log_resends); assert!(settings.logging.log_disk_cache); assert_eq!(settings.asset_cache.max_size, 1_073_741_824); assert_eq!(settings.default_effect_color.r, 1.0); assert_eq!(settings.upload_cost, 0); assert_eq!(settings.validate(), Ok(())); assert_eq!(Settings::PING_INTERVAL, 2_200); assert_eq!(Settings::NETWORK_TICK_INTERVAL, 500); assert_eq!(Settings::MAX_PACKET_SIZE, 1_200); assert_eq!(Settings::MAX_SEQUENCE, 0xFF_FFFF); assert_eq!(Settings::user_agent(), "LibreMetaverse"); assert_eq!(Settings::resource_dir(), "linden"); assert_eq!(Settings::max_http_connections(), 32); assert_eq!(Settings::packet_archive_size(), 1_000); assert_eq!(Settings::udp_receive_queue_capacity(), 512); assert_eq!(Settings::texture_pipeline_refresh_interval(), 500.0); assert_eq!(Settings::simulator_pool_timeout(), 120_000); assert!(Settings::bind_address().is_unspecified()); } #[test] fn validation_reports_the_exact_invalid_field() { let mut settings = Settings::defaults(); settings.timing.login_timeout = 0; assert_eq!( settings.validate(), Err(ClientCoreError::InvalidConfiguration { field: "Timing.LoginTimeout", reason: "must be positive", }) ); let mut settings = Settings::defaults(); settings.connection.login_server = "https://user:secret@example.test/login".to_owned(); assert!(matches!( settings.validate(), Err(ClientCoreError::InvalidConfiguration { field: "Connection.LoginServer", .. }) )); assert!(!format!("{settings:?}").contains("secret")); let mut settings = Settings::defaults(); settings.agent.send_updates = false; assert_eq!( settings.validate(), Err(ClientCoreError::InvalidConfiguration { field: "Agent.SendUpdatesRegularly", reason: "requires Agent.SendUpdates", }) ); } #[test] fn construction_is_inert_and_shutdown_is_ordered_and_idempotent() { let order = Arc::new(Mutex::new(Vec::new())); let manager = Arc::new(RecordingService { name: "manager", phase: ShutdownPhase::Manager, order: Arc::clone(&order), calls: AtomicUsize::new(0), }); let network = Arc::new(RecordingService { name: "network", phase: ShutdownPhase::Network, order: Arc::clone(&order), calls: AtomicUsize::new(0), }); let client = GridClient::builder() .with_service(manager.clone()) .with_service(network.clone()) .build() .unwrap(); assert_eq!(client.lifecycle_state(), ClientLifecycleState::Active); assert!(!client.cancellation_token().is_cancellation_requested()); client.shutdown().unwrap(); client.shutdown().unwrap(); assert_eq!(client.lifecycle_state(), ClientLifecycleState::Disposed); assert!(client.cancellation_token().is_cancellation_requested()); assert_eq!( *order .lock() .unwrap_or_else(std::sync::PoisonError::into_inner), vec!["network", "manager"] ); assert_eq!(network.calls.load(Ordering::Relaxed), 1); assert_eq!(manager.calls.load(Ordering::Relaxed), 1); } }