Files
MetaCrate/crates/libremetaverse/src/client_core.rs
Chili Palmer ed516a1a5d
Some checks failed
Native code generation / deterministic (push) Failing after 2m20s
Imaging and meshing gate / native (push) Failing after 1m25s
Native Rust workspace compile / compile (push) Failing after 56s
Implement avatar animation and skinning (#67)
2026-08-10 11:45:07 +00:00

1272 lines
42 KiB
Rust

//! 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<ClientCoreError> 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<dyn ClientService>,
}
struct ClientRuntime {
state: AtomicU8,
cancellation: CancellationTokenSource,
services: Mutex<Vec<ServiceRegistration>>,
agent_throttle_sender: Mutex<Option<Arc<dyn crate::udp_transport::AgentThrottleSender>>>,
http_caps_client: Mutex<crate::caps_http::HttpCapsClient>,
caps_rate_limiter: Mutex<crate::caps_http::CapsRateLimiter>,
network_manager: Mutex<std::sync::Weak<crate::network_manager::NetworkManagerInner>>,
agent_manager: Mutex<std::sync::Weak<crate::agent_manager::AgentManagerInner>>,
appearance_manager: Mutex<Option<Arc<crate::appearance_manager::AppearanceManagerInner>>>,
avatar_manager: Mutex<Option<Arc<crate::avatar_manager::AvatarManagerInner>>>,
animesh_manager: Mutex<Option<Arc<crate::animesh_runtime::AnimeshManagerInner>>>,
inventory_manager: Mutex<Option<Arc<crate::inventory_manager::InventoryManagerInner>>>,
inventory_ais_client: Mutex<Option<crate::inventory_ais::InventoryAISClient>>,
asset_manager: Mutex<Option<Arc<crate::asset_manager::AssetManagerInner>>>,
shutdown_complete: Condvar,
shutdown_wait: Mutex<()>,
}
impl ClientRuntime {
fn new(
services: Vec<Arc<dyn ClientService>>,
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),
network_manager: Mutex::new(std::sync::Weak::new()),
agent_manager: Mutex::new(std::sync::Weak::new()),
appearance_manager: Mutex::new(None),
avatar_manager: Mutex::new(None),
animesh_manager: Mutex::new(None),
inventory_manager: Mutex::new(None),
inventory_ais_client: Mutex::new(None),
asset_manager: Mutex::new(None),
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<dyn ClientService>) -> 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.inventory_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
self.inventory_ais_client
.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)
}
}
/// A non-owning route back to a live client runtime.
///
/// Manager caches can hold this handle without creating an `Arc` cycle. The
/// settings and time provider are immutable client composition data and are
/// cloned only when a manager needs to perform an operation.
#[derive(Clone)]
pub(crate) struct ClientWeakHandle {
runtime: std::sync::Weak<ClientRuntime>,
settings: Settings,
time_provider: TimeProvider,
}
impl ClientWeakHandle {
pub(crate) fn upgrade(&self) -> Option<GridClient> {
Some(GridClient {
settings: self.settings.clone(),
time_provider: self.time_provider.clone(),
runtime: self.runtime.upgrade()?,
network_manager: None,
agent_manager: None,
})
}
}
/// 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.
pub struct GridClient {
pub(crate) settings: Settings,
pub(crate) time_provider: TimeProvider,
runtime: Arc<ClientRuntime>,
network_manager: Option<crate::NetworkManager>,
agent_manager: Option<Box<crate::agent_manager::AgentManager>>,
}
impl Clone for GridClient {
fn clone(&self) -> Self {
Self {
settings: self.settings.clone(),
time_provider: self.time_provider.clone(),
runtime: Arc::clone(&self.runtime),
network_manager: self.network_manager.clone(),
agent_manager: None,
}
}
}
#[cfg(test)]
pub(crate) struct ClientRetentionProbe(std::sync::Weak<ClientRuntime>);
#[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<dyn ClientService>,
) -> 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<dyn crate::udp_transport::AgentThrottleSender>,
) -> 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<Arc<dyn crate::udp_transport::AgentThrottleSender>> {
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_network(&self) -> Result<crate::NetworkManager, crate::Error> {
if let Some(manager) = &self.network_manager {
return Ok(manager.clone());
}
let mut cached = self
.runtime
.network_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(inner) = cached.upgrade() {
return Ok(crate::network_manager::NetworkManager::native_from_inner(
inner,
));
}
let manager = crate::network_manager::NetworkManager::native_new(self.clone())?;
*cached = manager.native_inner_weak();
Ok(manager)
}
pub(crate) fn native_weak_handle(&self) -> ClientWeakHandle {
ClientWeakHandle {
runtime: Arc::downgrade(&self.runtime),
settings: self.settings.clone(),
time_provider: self.time_provider.clone(),
}
}
pub(crate) fn native_inventory(&self) -> Result<crate::InventoryManager, crate::Error> {
let mut cached = self
.runtime
.inventory_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(inner) = cached.as_ref() {
return Ok(
crate::inventory_manager::InventoryManager::native_from_inner(Arc::clone(inner)),
);
}
let manager =
crate::inventory_manager::InventoryManager::native_new(Some(Arc::new(self.clone())))?;
*cached = Some(manager.native_inner());
Ok(manager)
}
pub(crate) fn native_appearance(&self) -> Result<crate::AppearanceManager, crate::Error> {
let mut cached = self
.runtime
.appearance_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(inner) = cached.as_ref() {
return Ok(
crate::appearance_manager::AppearanceManager::native_from_inner(Arc::clone(inner)),
);
}
let manager =
crate::appearance_manager::AppearanceManager::native_new(Some(Arc::new(self.clone())))?;
*cached = Some(manager.native_inner());
Ok(manager)
}
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn native_set_appearance(&mut self, value: crate::AppearanceManager) {
*self
.runtime
.appearance_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.native_inner());
}
pub(crate) fn native_assets(&self) -> Result<crate::AssetManager, crate::Error> {
let mut cached = self
.runtime
.asset_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(inner) = cached.as_ref() {
return crate::asset_manager::AssetManager::native_from_inner(Arc::clone(inner));
}
let manager = crate::asset_manager::AssetManager::new(Some(self.clone()))?;
*cached = Some(manager.native_inner());
Ok(manager)
}
pub(crate) fn native_avatars(&self) -> Result<crate::AvatarManager, crate::Error> {
let mut cached = self
.runtime
.avatar_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(inner) = cached.as_ref() {
return Ok(crate::avatar_manager::AvatarManager::native_from_inner(
Arc::clone(inner),
));
}
let manager =
crate::avatar_manager::AvatarManager::native_new(Some(Arc::new(self.clone())))?;
*cached = Some(manager.native_inner());
Ok(manager)
}
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn native_set_avatars(&mut self, value: crate::AvatarManager) {
*self
.runtime
.avatar_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.native_inner());
}
pub(crate) fn native_animesh(&self) -> Result<crate::animesh::AnimeshManager, crate::Error> {
let mut cached = self
.runtime
.animesh_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(inner) = cached.as_ref() {
return Ok(crate::animesh_runtime::AnimeshManager::native_from_inner(
Arc::clone(inner),
));
}
let manager = crate::animesh_runtime::AnimeshManager::native_new(self.clone())?;
*cached = Some(manager.native_inner());
Ok(manager)
}
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn native_set_animesh(&mut self, value: crate::animesh::AnimeshManager) {
*self
.runtime
.animesh_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.native_inner());
}
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn native_set_assets(&mut self, value: crate::AssetManager) {
*self
.runtime
.asset_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.native_inner());
}
pub(crate) fn native_ais_client(&self) -> Result<crate::InventoryAISClient, crate::Error> {
let mut cached = self
.runtime
.inventory_ais_client
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(client) = cached.as_ref() {
return Ok(client.clone());
}
let client =
crate::inventory_ais::InventoryAISClient::native_new(Some(Arc::new(self.clone())))?;
*cached = Some(client.clone());
Ok(client)
}
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn native_set_ais_client(&mut self, value: crate::InventoryAISClient) {
*self
.runtime
.inventory_ais_client
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value);
}
#[allow(clippy::needless_pass_by_value)] // The mapped C# property setter owns its value.
pub(crate) fn native_set_inventory(&mut self, value: crate::InventoryManager) {
*self
.runtime
.inventory_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.native_inner());
}
#[allow(clippy::needless_pass_by_value)] // The mapped C# property setter owns its value.
pub(crate) fn native_set_network(&mut self, value: crate::NetworkManager) {
*self
.runtime
.network_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = value.native_inner_weak();
self.network_manager = Some(value);
}
pub(crate) fn native_self(&mut self) -> &mut crate::agent_manager::AgentManager {
if self.agent_manager.is_none() {
let client = Arc::new(self.clone());
let manager = crate::agent_manager::AgentManager::native_new(Some(client))
.unwrap_or_else(|_| panic!("failed to construct AgentManager"));
self.agent_manager = Some(Box::new(manager));
}
self.agent_manager.as_deref_mut().expect("agent manager")
}
pub(crate) fn cached_agent_manager_inner(
&self,
) -> Option<Arc<crate::agent_manager::AgentManagerInner>> {
self.runtime
.agent_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.upgrade()
}
pub(crate) fn cache_agent_manager_inner(
&self,
inner: &Arc<crate::agent_manager::AgentManagerInner>,
) {
*self
.runtime
.agent_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Arc::downgrade(inner);
}
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_non_exhaustive()
}
}
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<Arc<dyn ClientService>>,
}
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<dyn ClientService>) -> 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<GridClient, ClientCoreError> {
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,
)),
network_manager: None,
agent_manager: None,
})
}
}
/// 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", &"<redacted endpoint>")
.field("mfa_enabled", &self.mfa_enabled)
.finish()
}
}
fn invalid<T>(field: &'static str, reason: &'static str) -> Result<T, ClientCoreError> {
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<Mutex<Vec<&'static str>>>,
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);
}
}