diff --git a/README.md b/README.md index dd1e3d2..b155ee2 100644 --- a/README.md +++ b/README.md @@ -250,3 +250,15 @@ visual-parameter values. Native skeleton traversal, alias lookup, expanded mesh joint lists, custom XML loading, attention indexing, and archetype lookup now implement the mapped behavior directly in Rust. Each checked-in catalog retains its own input hash and license provenance. + +### Milestone 08 + +The native client core now provides the exact grouped `Settings` defaults from +the golden C# implementation, validates endpoints, durations, limits, cache +policy, and download policy, and exposes typed configuration and lifecycle +errors. `GridClient` construction and drop have no network side effects and do +not create an async runtime. Explicitly composed services share a cancellation +token and are shut down idempotently in network, manager, HTTP, then rate-limiter +order. Injected clocks support deterministic tests, while `Debug` output omits +endpoint values and service internals. The ownership and executor requirements +are documented in [`docs/client-core.md`](docs/client-core.md). diff --git a/api/SHIM-COVERAGE.md b/api/SHIM-COVERAGE.md index bb26c4e..e365e9c 100644 --- a/api/SHIM-COVERAGE.md +++ b/api/SHIM-COVERAGE.md @@ -4,7 +4,7 @@ Generated by `python3 tools/generate_api_shims.py`; do not edit by hand. | Assembly | Types | Members | Status | |---|---:|---:|---| -| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 26 types / 13,384 members; remaining surface is callable failure-only shims | +| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 28 types / 13,428 members; remaining surface is callable failure-only shims | | `LibreMetaverse.Imaging.Abstractions` | 3 | 20 | native implementation: 3 types / 20 members; no generated shims remain | | `LibreMetaverse.Imaging.Skia` | 1 | 3 | native implementation: 1 type / 3 members; no generated shims remain | | `LibreMetaverse.LslTools` | 164 | 768 | callable failure-only shim | diff --git a/crates/libremetaverse-types/src/compat.rs b/crates/libremetaverse-types/src/compat.rs index 31f6d23..53f96a7 100644 --- a/crates/libremetaverse-types/src/compat.rs +++ b/crates/libremetaverse-types/src/compat.rs @@ -12,9 +12,10 @@ use std::hash::Hash; use std::marker::PhantomData; use std::pin::Pin; use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, + Arc, Mutex, Weak, + atomic::{AtomicBool, AtomicU64, Ordering}, }; +use std::task::{Context, Poll, Waker}; pub trait Collection {} @@ -289,13 +290,179 @@ pub struct XmlTextReader(pub String); #[derive(Clone, Debug, Eq, PartialEq)] pub struct XmlTextWriter(pub String); -#[derive(Clone, Debug, Default)] -pub struct CancellationToken(Arc); +struct CancellationState { + cancelled: AtomicBool, + next_id: AtomicU64, + waiters: Mutex>, + callbacks: Mutex>>, +} + +impl Default for CancellationState { + fn default() -> Self { + Self { + cancelled: AtomicBool::new(false), + next_id: AtomicU64::new(1), + waiters: Mutex::new(BTreeMap::new()), + callbacks: Mutex::new(BTreeMap::new()), + } + } +} + +/// A cheap, cloneable cancellation signal. +/// +/// Waiting is executor-neutral: no thread or async runtime is created by this +/// type. Cancelling wakes every registered future and invokes linked-token +/// callbacks after releasing internal locks. +#[derive(Clone, Default)] +pub struct CancellationToken(Arc); + +impl fmt::Debug for CancellationToken { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CancellationToken") + .field( + "is_cancellation_requested", + &self.is_cancellation_requested(), + ) + .finish_non_exhaustive() + } +} impl CancellationToken { #[must_use] pub fn is_cancellation_requested(&self) -> bool { - self.0.load(Ordering::Acquire) + self.0.cancelled.load(Ordering::Acquire) + } + + /// Returns a future that completes when cancellation is requested. + #[must_use] + pub fn cancelled(&self) -> CancellationFuture { + CancellationFuture { + state: Arc::clone(&self.0), + waiter_id: None, + } + } + + /// Converts an observed cancellation request to the shared typed error. + /// + /// # Errors + /// + /// Returns [`crate::Error::Cancelled`] after the token has been cancelled. + pub fn throw_if_cancellation_requested(&self) -> Result<(), crate::Error> { + if self.is_cancellation_requested() { + Err(crate::Error::Cancelled) + } else { + Ok(()) + } + } + + fn register(&self, callback: Arc) -> CancellationRegistration { + if self.is_cancellation_requested() { + callback(); + return CancellationRegistration::completed(); + } + + let id = self.0.next_id.fetch_add(1, Ordering::Relaxed); + self.0 + .callbacks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(id, callback); + if self.is_cancellation_requested() { + let callback = self + .0 + .callbacks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&id); + if let Some(callback) = callback { + callback(); + } + CancellationRegistration::completed() + } else { + CancellationRegistration { + state: Arc::downgrade(&self.0), + id: Some(id), + } + } + } +} + +/// Future returned by [`CancellationToken::cancelled`]. +pub struct CancellationFuture { + state: Arc, + waiter_id: Option, +} + +impl Future for CancellationFuture { + type Output = (); + + fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll { + if self.state.cancelled.load(Ordering::Acquire) { + return Poll::Ready(()); + } + + let id = if let Some(id) = self.waiter_id { + id + } else { + let id = self.state.next_id.fetch_add(1, Ordering::Relaxed); + self.waiter_id = Some(id); + id + }; + self.state + .waiters + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(id, context.waker().clone()); + if self.state.cancelled.load(Ordering::Acquire) { + self.state + .waiters + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&id); + self.waiter_id = None; + Poll::Ready(()) + } else { + Poll::Pending + } + } +} + +impl Drop for CancellationFuture { + fn drop(&mut self) { + if let Some(id) = self.waiter_id { + self.state + .waiters + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&id); + } + } +} + +struct CancellationRegistration { + state: Weak, + id: Option, +} + +impl CancellationRegistration { + fn completed() -> Self { + Self { + state: Weak::new(), + id: None, + } + } +} + +impl Drop for CancellationRegistration { + fn drop(&mut self) { + if let (Some(state), Some(id)) = (self.state.upgrade(), self.id) { + state + .callbacks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&id); + } } } @@ -310,8 +477,35 @@ pub struct DictionaryEntry(pub Object, pub Object); pub struct RateLimitLease; -#[derive(Clone, Debug, Default)] -pub struct CancellationTokenSource(CancellationToken); +struct CancellationSourceState { + token: CancellationToken, + linked_registrations: Mutex>, +} + +/// Owns and triggers a [`CancellationToken`]. +#[derive(Clone)] +pub struct CancellationTokenSource(Arc); + +impl Default for CancellationTokenSource { + fn default() -> Self { + Self(Arc::new(CancellationSourceState { + token: CancellationToken::default(), + linked_registrations: Mutex::new(Vec::new()), + })) + } +} + +impl fmt::Debug for CancellationTokenSource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CancellationTokenSource") + .field( + "is_cancellation_requested", + &self.0.token.is_cancellation_requested(), + ) + .finish_non_exhaustive() + } +} impl CancellationTokenSource { #[must_use] @@ -319,13 +513,62 @@ impl CancellationTokenSource { Self::default() } + /// Creates a source cancelled when any parent token is cancelled. + #[must_use] + pub fn new_linked(tokens: &[CancellationToken]) -> Self { + let source = Self::new(); + let mut registrations = Vec::with_capacity(tokens.len()); + for token in tokens { + let linked = Arc::downgrade(&source.0); + registrations.push(token.register(Arc::new(move || { + if let Some(linked) = linked.upgrade() { + Self::cancel_state(&linked); + } + }))); + } + *source + .0 + .linked_registrations + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = registrations; + source + } + #[must_use] pub fn token(&self) -> CancellationToken { - self.0.clone() + self.0.token.clone() } pub fn cancel(&self) { - self.0.0.store(true, Ordering::Release); + Self::cancel_state(&self.0); + } + + fn cancel_state(state: &CancellationSourceState) { + if state.token.0.cancelled.swap(true, Ordering::AcqRel) { + return; + } + let waiters = std::mem::take( + &mut *state + .token + .0 + .waiters + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + let callbacks = std::mem::take( + &mut *state + .token + .0 + .callbacks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + for (_, waker) in waiters { + waker.wake(); + } + for (_, callback) in callbacks { + callback(); + } } } @@ -347,7 +590,43 @@ pub struct Task(pub PhantomData T>); pub struct TaskCompletionSource(pub PhantomData); -pub struct TimeProvider; +/// Cloneable clock boundary used by runtime-neutral client code. +#[derive(Clone)] +pub struct TimeProvider(Arc std::time::SystemTime + Send + Sync>); + +impl TimeProvider { + /// Returns the process system clock. + #[must_use] + pub fn system() -> Self { + Self(Arc::new(std::time::SystemTime::now)) + } + + /// Creates an injected clock for deterministic tests and embedders. + #[must_use] + pub fn from_fn(clock: F) -> Self + where + F: Fn() -> std::time::SystemTime + Send + Sync + 'static, + { + Self(Arc::new(clock)) + } + + #[must_use] + pub fn get_utc_now(&self) -> std::time::SystemTime { + (self.0)() + } +} + +impl Default for TimeProvider { + fn default() -> Self { + Self::system() + } +} + +impl fmt::Debug for TimeProvider { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("TimeProvider()") + } +} pub struct MediaTypeHeaderValue(pub String); @@ -459,11 +738,44 @@ pub struct SocketException; #[cfg(test)] mod tests { - use super::{CancellationToken, HttpMessageHandler, HttpRequest, HttpResponse, Object, Uri}; + use super::{ + CancellationToken, CancellationTokenSource, HttpMessageHandler, HttpRequest, HttpResponse, + Object, TimeProvider, Uri, + }; use std::collections::BTreeMap; use std::future::Future; use std::sync::Arc; use std::task::{Context, Poll, Waker}; + use std::time::{Duration, SystemTime}; + + #[test] + fn cancellation_wakes_waiters_and_propagates_from_linked_tokens() { + let parent = CancellationTokenSource::new(); + let linked = CancellationTokenSource::new_linked(&[parent.token()]); + let mut future = Box::pin(linked.token().cancelled()); + let mut context = Context::from_waker(Waker::noop()); + assert!(matches!(future.as_mut().poll(&mut context), Poll::Pending)); + + parent.cancel(); + assert!(linked.token().is_cancellation_requested()); + assert!(matches!( + future.as_mut().poll(&mut context), + Poll::Ready(()) + )); + assert_eq!( + linked.token().throw_if_cancellation_requested(), + Err(crate::Error::Cancelled) + ); + parent.cancel(); + } + + #[test] + fn injected_time_provider_is_deterministic_and_redacted() { + let instant = SystemTime::UNIX_EPOCH + Duration::from_secs(42); + let provider = TimeProvider::from_fn(move || instant); + assert_eq!(provider.get_utc_now(), instant); + assert!(!format!("{provider:?}").contains("42")); + } #[test] fn http_handler_preserves_request_and_response_data() { diff --git a/crates/libremetaverse/src/client_core.rs b/crates/libremetaverse/src/client_core.rs new file mode 100644 index 0000000..fe7faad --- /dev/null +++ b/crates/libremetaverse/src/client_core.rs @@ -0,0 +1,833 @@ +//! 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>, + shutdown_complete: Condvar, + shutdown_wait: Mutex<()>, +} + +impl ClientRuntime { + fn new(services: Vec>) -> 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(), + ), + 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(); + 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. +pub struct GridClient { + pub(crate) settings: Settings, + pub(crate) time_provider: TimeProvider, + runtime: Arc, +} + +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) + } + + pub(crate) fn shutdown(&self) -> Result<(), ClientCoreError> { + self.runtime.shutdown() + } +} + +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) { + let _ = self.runtime.shutdown(); + } +} + +impl crate::IGridClient for GridClient { + 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()?; + Ok(GridClient { + settings: self.settings, + time_provider: self.time_provider, + runtime: Arc::new(ClientRuntime::new(self.services)), + }) + } +} + +/// 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, + } + } + + #[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] + 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); + } +} diff --git a/crates/libremetaverse/src/generated.rs b/crates/libremetaverse/src/generated.rs index 317727d..4f7a6b4 100644 --- a/crates/libremetaverse/src/generated.rs +++ b/crates/libremetaverse/src/generated.rs @@ -3296,6 +3296,7 @@ impl AgentPreferencesEventArgs { } /// C# type: `T:LibreMetaverse.AgentSettings`. +#[derive(Clone, Debug, Eq, PartialEq)] pub struct AgentSettings { /// C# member: `F:LibreMetaverse.AgentSettings.DisableUpdateDuplicateCheck`. pub disable_update_duplicate_check: bool, @@ -3313,7 +3314,8 @@ pub struct AgentSettings { impl AgentSettings { /// C# member: `M:LibreMetaverse.AgentSettings.#ctor`. pub fn new() -> Result { - libremetaverse_types::not_implemented("M:LibreMetaverse.AgentSettings.#ctor") + /* native client-core implementation */ + Ok(crate::client_core::agent_settings_defaults()) } } @@ -4976,6 +4978,7 @@ impl AssetCacheComputeAssetCacheFilenameDelegate { } /// C# type: `T:LibreMetaverse.AssetCacheSettings`. +#[derive(Clone, Debug, Eq, PartialEq)] pub struct AssetCacheSettings { /// C# member: `F:LibreMetaverse.AssetCacheSettings.Dir`. pub dir: String, @@ -4987,7 +4990,8 @@ pub struct AssetCacheSettings { impl AssetCacheSettings { /// C# member: `M:LibreMetaverse.AssetCacheSettings.#ctor`. pub fn new() -> Result { - libremetaverse_types::not_implemented("M:LibreMetaverse.AssetCacheSettings.#ctor") + /* native client-core implementation */ + Ok(crate::client_core::asset_cache_settings_defaults()) } } @@ -7493,6 +7497,7 @@ impl CompressedFlags { } /// C# type: `T:LibreMetaverse.ConnectionSettings`. +#[derive(Clone, Eq, PartialEq)] pub struct ConnectionSettings { /// C# member: `F:LibreMetaverse.ConnectionSettings.LoginServer`. pub login_server: String, @@ -7502,7 +7507,8 @@ pub struct ConnectionSettings { impl ConnectionSettings { /// C# member: `M:LibreMetaverse.ConnectionSettings.#ctor`. pub fn new() -> Result { - libremetaverse_types::not_implemented("M:LibreMetaverse.ConnectionSettings.#ctor") + /* native client-core implementation */ + Ok(crate::client_core::connection_settings_defaults()) } } @@ -10958,23 +10964,29 @@ pub use crate::foliage_catalog::GrassDefinition; pub use crate::foliage_catalog::GrassDefinitions; /// C# type: `T:LibreMetaverse.GridClient`. -pub struct GridClient; +pub use crate::client_core::GridClient; impl GridClient { /// C# member: `M:LibreMetaverse.GridClient.#ctor`. pub fn new() -> Result { - libremetaverse_types::not_implemented("M:LibreMetaverse.GridClient.#ctor") + /* native client-core implementation */ + crate::client_core::GridClientBuilder::default() + .build() + .map_err(Into::into) } /// C# member: `M:LibreMetaverse.GridClient.Dispose`. pub fn dispose_with_method(&self) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented("M:LibreMetaverse.GridClient.Dispose") + /* native client-core implementation */ + self.shutdown().map_err(Into::into) } /// C# member: `M:LibreMetaverse.GridClient.DisposeAsync`. pub async fn dispose_with_method_87ebceff(&self) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented("M:LibreMetaverse.GridClient.DisposeAsync") + /* native client-core implementation */ + self.shutdown().map_err(Into::into) } /// C# member: `M:LibreMetaverse.GridClient.ToString`. pub fn to_string(&self) -> String { - libremetaverse_types::unimplemented_api!("M:LibreMetaverse.GridClient.ToString") + /* native client-core implementation */ + String::new() } /// C# member: `P:LibreMetaverse.GridClient.AisClient`. pub fn ais_client(&self) -> libremetaverse::InventoryAISClient { @@ -11134,7 +11146,8 @@ impl GridClient { } /// C# member: `P:LibreMetaverse.GridClient.Settings`. pub fn settings(&mut self) -> &mut libremetaverse::Settings { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.GridClient.Settings") + /* native client-core implementation */ + &mut self.settings } /// C# member: `P:LibreMetaverse.GridClient.Sound`. pub fn sound(&self) -> libremetaverse::SoundManager { @@ -11170,14 +11183,15 @@ impl GridClient { } /// C# member: `P:LibreMetaverse.GridClient.TimeProvider`. pub fn time_provider(&self) -> libremetaverse_types::compat::TimeProvider { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.GridClient.TimeProvider") + /* native client-core implementation */ + self.time_provider.clone() } /// Setter for C# member: `P:LibreMetaverse.GridClient.TimeProvider`. pub fn set_time_provider(&mut self, value: libremetaverse_types::compat::TimeProvider) { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.GridClient.TimeProvider") + /* native client-core implementation */ + self.time_provider = value } } -impl libremetaverse::IGridClient for GridClient {} /// C# type: `T:LibreMetaverse.GridClientBakingTextureProvider`. pub struct GridClientBakingTextureProvider; @@ -17618,6 +17632,7 @@ impl LoggerLogCallback { } /// C# type: `T:LibreMetaverse.LoggingSettings`. +#[derive(Clone, Debug, Eq, PartialEq)] pub struct LoggingSettings { /// C# member: `F:LibreMetaverse.LoggingSettings.LogDiskCache`. pub log_disk_cache: bool, @@ -17629,7 +17644,8 @@ pub struct LoggingSettings { impl LoggingSettings { /// C# member: `M:LibreMetaverse.LoggingSettings.#ctor`. pub fn new() -> Result { - libremetaverse_types::not_implemented("M:LibreMetaverse.LoggingSettings.#ctor") + /* native client-core implementation */ + Ok(crate::client_core::logging_settings_defaults()) } } @@ -21519,6 +21535,7 @@ impl PacketSentEventArgs { } /// C# type: `T:LibreMetaverse.PacketSettings`. +#[derive(Clone, Debug, Eq, PartialEq)] pub struct PacketSettings { /// C# member: `F:LibreMetaverse.PacketSettings.EnableSimStats`. pub enable_sim_stats: bool, @@ -21538,7 +21555,8 @@ pub struct PacketSettings { impl PacketSettings { /// C# member: `M:LibreMetaverse.PacketSettings.#ctor`. pub fn new() -> Result { - libremetaverse_types::not_implemented("M:LibreMetaverse.PacketSettings.#ctor") + /* native client-core implementation */ + Ok(crate::client_core::packet_settings_defaults()) } } @@ -22666,6 +22684,7 @@ pub enum ParcelResult { } /// C# type: `T:LibreMetaverse.ParcelSettings`. +#[derive(Clone, Debug, Eq, PartialEq)] pub struct ParcelSettings { /// C# member: `F:LibreMetaverse.ParcelSettings.AlwaysRequestAcl`. pub always_request_acl: bool, @@ -22679,7 +22698,8 @@ pub struct ParcelSettings { impl ParcelSettings { /// C# member: `M:LibreMetaverse.ParcelSettings.#ctor`. pub fn new() -> Result { - libremetaverse_types::not_implemented("M:LibreMetaverse.ParcelSettings.#ctor") + /* native client-core implementation */ + Ok(crate::client_core::parcel_settings_defaults()) } } @@ -25426,10 +25446,8 @@ impl SetDisplayNameReplyEventArgs { } /// C# type: `T:LibreMetaverse.Settings`. -pub struct Settings { - /// C# member: `F:LibreMetaverse.Settings.DefaultEffectColor`. - pub default_effect_color: libremetaverse_types::Color4, -} +pub use crate::client_core::Settings; +/// C# member: `F:LibreMetaverse.Settings.DefaultEffectColor`. impl Settings { /// C# member: `F:LibreMetaverse.Settings.AditiLoginServer`. pub const ADITI_LOGIN_SERVER: &'static str = @@ -25439,7 +25457,8 @@ impl Settings { "https://login.agni.lindenlab.com/cgi-bin/login.cgi"; /// C# member: `F:LibreMetaverse.Settings.BindAddress`. pub fn bind_address() -> std::net::IpAddr { - libremetaverse_types::unimplemented_api!("F:LibreMetaverse.Settings.BindAddress") + /* native client-core implementation */ + std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED) } /// C# member: `F:LibreMetaverse.Settings.EnableInventoryStore`. pub const ENABLE_INVENTORY_STORE: bool = true; @@ -25447,11 +25466,13 @@ impl Settings { pub const ENABLE_LIBRARY_STORE: bool = true; /// C# member: `F:LibreMetaverse.Settings.LogLevel`. pub fn log_level() -> libremetaverse::logging::LogLevel { - libremetaverse_types::unimplemented_api!("F:LibreMetaverse.Settings.LogLevel") + /* native client-core implementation */ + crate::logging::LogLevel(1) } /// C# member: `F:LibreMetaverse.Settings.MaxHttpConnections`. pub fn max_http_connections() -> i32 { - libremetaverse_types::unimplemented_api!("F:LibreMetaverse.Settings.MaxHttpConnections") + /* native client-core implementation */ + 32 } /// C# member: `F:LibreMetaverse.Settings.MaxPacketSize`. pub const MAX_PACKET_SIZE: i32 = 1200; @@ -25461,83 +25482,95 @@ impl Settings { pub const NETWORK_TICK_INTERVAL: i32 = 500; /// C# member: `F:LibreMetaverse.Settings.PacketArchiveSize`. pub fn packet_archive_size() -> i32 { - libremetaverse_types::unimplemented_api!("F:LibreMetaverse.Settings.PacketArchiveSize") + /* native client-core implementation */ + 1000 } /// C# member: `F:LibreMetaverse.Settings.PingInterval`. pub const PING_INTERVAL: i32 = 2200; /// C# member: `F:LibreMetaverse.Settings.ResourceDir`. pub fn resource_dir() -> String { - libremetaverse_types::unimplemented_api!("F:LibreMetaverse.Settings.ResourceDir") + /* native client-core implementation */ + "linden".to_owned() } /// C# member: `F:LibreMetaverse.Settings.SimulatorPoolTimeout`. pub fn simulator_pool_timeout() -> i32 { - libremetaverse_types::unimplemented_api!("F:LibreMetaverse.Settings.SimulatorPoolTimeout") + /* native client-core implementation */ + 120_000 } /// C# member: `F:LibreMetaverse.Settings.TexturePipelineRefreshInterval`. pub fn texture_pipeline_refresh_interval() -> f32 { - libremetaverse_types::unimplemented_api!( - "F:LibreMetaverse.Settings.TexturePipelineRefreshInterval" - ) + /* native client-core implementation */ + 500.0 } /// C# member: `F:LibreMetaverse.Settings.UdpReceiveQueueCapacity`. pub fn udp_receive_queue_capacity() -> i32 { - libremetaverse_types::unimplemented_api!( - "F:LibreMetaverse.Settings.UdpReceiveQueueCapacity" - ) + /* native client-core implementation */ + 512 } /// C# member: `F:LibreMetaverse.Settings.UserAgent`. pub fn user_agent() -> String { - libremetaverse_types::unimplemented_api!("F:LibreMetaverse.Settings.UserAgent") + /* native client-core implementation */ + "LibreMetaverse".to_owned() } /// C# member: `M:LibreMetaverse.Settings.#ctor(LibreMetaverse.GridClient)`. pub fn new(client: libremetaverse::GridClient) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Settings.#ctor(LibreMetaverse.GridClient)", - ) + /* native client-core implementation */ + Ok(crate::client_core::Settings::defaults()) } /// C# member: `P:LibreMetaverse.Settings.Agent`. pub fn agent(&self) -> libremetaverse::AgentSettings { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.Settings.Agent") + /* native client-core implementation */ + self.agent.clone() } /// C# member: `P:LibreMetaverse.Settings.AssetCache`. pub fn asset_cache(&self) -> libremetaverse::AssetCacheSettings { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.Settings.AssetCache") + /* native client-core implementation */ + self.asset_cache.clone() } /// C# member: `P:LibreMetaverse.Settings.Connection`. pub fn connection(&self) -> libremetaverse::ConnectionSettings { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.Settings.Connection") + /* native client-core implementation */ + self.connection.clone() } /// C# member: `P:LibreMetaverse.Settings.Logging`. pub fn logging(&self) -> libremetaverse::LoggingSettings { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.Settings.Logging") + /* native client-core implementation */ + self.logging.clone() } /// C# member: `P:LibreMetaverse.Settings.Packets`. pub fn packets(&self) -> libremetaverse::PacketSettings { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.Settings.Packets") + /* native client-core implementation */ + self.packets.clone() } /// C# member: `P:LibreMetaverse.Settings.Parcel`. pub fn parcel(&self) -> libremetaverse::ParcelSettings { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.Settings.Parcel") + /* native client-core implementation */ + self.parcel.clone() } /// C# member: `P:LibreMetaverse.Settings.TexturePipeline`. pub fn texture_pipeline(&self) -> libremetaverse::TexturePipelineSettings { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.Settings.TexturePipeline") + /* native client-core implementation */ + self.texture_pipeline.clone() } /// C# member: `P:LibreMetaverse.Settings.Timing`. pub fn timing(&mut self) -> &mut libremetaverse::TimingSettings { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.Settings.Timing") + /* native client-core implementation */ + &mut self.timing } /// C# member: `P:LibreMetaverse.Settings.UploadCost`. pub fn upload_cost(&self) -> i32 { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.Settings.UploadCost") + /* native client-core implementation */ + self.upload_cost } /// Setter for C# member: `P:LibreMetaverse.Settings.UploadCost`. pub fn set_upload_cost(&mut self, value: i32) { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.Settings.UploadCost") + /* native client-core implementation */ + self.upload_cost = value } /// C# member: `P:LibreMetaverse.Settings.World`. pub fn world(&self) -> libremetaverse::WorldSettings { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.Settings.World") + /* native client-core implementation */ + self.world.clone() } } @@ -27658,6 +27691,7 @@ impl TexturePipeline { } /// C# type: `T:LibreMetaverse.TexturePipelineSettings`. +#[derive(Clone, Debug, Eq, PartialEq)] pub struct TexturePipelineSettings { /// C# member: `F:LibreMetaverse.TexturePipelineSettings.Enabled`. pub enabled: bool, @@ -27671,7 +27705,8 @@ pub struct TexturePipelineSettings { impl TexturePipelineSettings { /// C# member: `M:LibreMetaverse.TexturePipelineSettings.#ctor`. pub fn new() -> Result { - libremetaverse_types::not_implemented("M:LibreMetaverse.TexturePipelineSettings.#ctor") + /* native client-core implementation */ + Ok(crate::client_core::texture_pipeline_settings_defaults()) } } @@ -27696,6 +27731,7 @@ pub enum TextureRequestState { } /// C# type: `T:LibreMetaverse.TimingSettings`. +#[derive(Clone, Debug, Eq, PartialEq)] pub struct TimingSettings { /// C# member: `F:LibreMetaverse.TimingSettings.AgentUpdateInterval`. pub agent_update_interval: i32, @@ -27721,7 +27757,8 @@ pub struct TimingSettings { impl TimingSettings { /// C# member: `M:LibreMetaverse.TimingSettings.#ctor`. pub fn new() -> Result { - libremetaverse_types::not_implemented("M:LibreMetaverse.TimingSettings.#ctor") + /* native client-core implementation */ + Ok(crate::client_core::timing_settings_defaults()) } } @@ -28359,6 +28396,7 @@ pub struct Vote { impl Vote {} /// C# type: `T:LibreMetaverse.WorldSettings`. +#[derive(Clone, Debug, Eq, PartialEq)] pub struct WorldSettings { /// C# member: `F:LibreMetaverse.WorldSettings.AlwaysDecodeObjects`. pub always_decode_objects: bool, @@ -28378,7 +28416,8 @@ pub struct WorldSettings { impl WorldSettings { /// C# member: `M:LibreMetaverse.WorldSettings.#ctor`. pub fn new() -> Result { - libremetaverse_types::not_implemented("M:LibreMetaverse.WorldSettings.#ctor") + /* native client-core implementation */ + Ok(crate::client_core::world_settings_defaults()) } } diff --git a/crates/libremetaverse/src/lib.rs b/crates/libremetaverse/src/lib.rs index 152bd5e..e867b9b 100644 --- a/crates/libremetaverse/src/lib.rs +++ b/crates/libremetaverse/src/lib.rs @@ -5,6 +5,7 @@ extern crate self as libremetaverse; #[rustfmt::skip] // Deterministic machine output is formatted by the pinned generator. mod attention_catalog; mod bit_pack; +mod client_core; #[rustfmt::skip] // Deterministic machine output is formatted by the pinned generator. mod foliage_catalog; mod generated; @@ -195,6 +196,9 @@ impl imaging::Baker { } } +pub use client_core::{ + ClientCoreError, ClientLifecycleState, ClientService, GridClientBuilder, ShutdownPhase, +}; pub use generated::*; pub use libremetaverse_imaging as imaging_abstractions; pub use libremetaverse_structured_data as structured_data; diff --git a/docs/client-core.md b/docs/client-core.md new file mode 100644 index 0000000..14b0dbb --- /dev/null +++ b/docs/client-core.md @@ -0,0 +1,45 @@ +# Client core ownership and runtime contract + +`GridClient` construction is deliberately inert. `GridClient::new()` validates +the C#-compatible defaults and allocates local ownership state, but it does not +create an async runtime, spawn a task, open a socket, construct a hidden global +service locator, or make an HTTP request. + +Applications that need custom settings, a deterministic clock, or native +manager implementations use the explicit builder: + +```rust +use libremetaverse::{GridClient, Settings}; +use libremetaverse_types::compat::TimeProvider; +use std::time::{Duration, SystemTime}; + +let mut settings = Settings::default(); +settings.timing().login_timeout = 30_000; + +let fixed = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000); +let client = GridClient::builder() + .with_settings(settings) + .with_time_provider(TimeProvider::from_fn(move || fixed)) + .build()?; +# Ok::<(), libremetaverse::ClientCoreError>(()) +``` + +Native services implement `ClientService` and are passed to +`GridClientBuilder::with_service` after the application has constructed them. +The application or service is responsible for using an existing executor; +library code never starts a nested runtime. Every owned task must observe +`GridClient::cancellation_token()` and be joined by its service's `shutdown` +implementation. + +Shutdown is idempotent. The client first requests cancellation, then shuts down +services in `Network`, `Manager`, `Http`, and `RateLimiter` phase order, matching +the golden C# resource dependency order. Dropping a client performs the same +shutdown path. Services must release their tasks and resources before returning. +User callbacks must never run while a service holds an internal lock. + +`Settings::validate` checks endpoint shape, positive timeouts and intervals, +packet and download limits, and enabled-cache path/size policy. Builder errors +identify the public field without copying its value. `Debug` output redacts the +login endpoint, injected clocks, and service internals; credentials and +capability URLs must be passed only to the operation that uses them and must +never be placed in service names or diagnostic errors. diff --git a/tests/compat/tests/core_runtime_shims.rs b/tests/compat/tests/core_runtime_shims.rs index 14ef375..dd9e8a0 100644 --- a/tests/compat/tests/core_runtime_shims.rs +++ b/tests/compat/tests/core_runtime_shims.rs @@ -72,23 +72,40 @@ fn runtime_flows_have_typed_callable_signatures() { } #[test] -fn core_constructors_fail_before_starting_runtime_work() { +fn client_and_settings_construct_without_starting_runtime_work() { + let mut client = GridClient::new().expect("GridClient constructor"); + assert_eq!(client.settings().timing().login_timeout, 60_000); assert_eq!( - member_id(GridClient::new()), - "M:LibreMetaverse.GridClient.#ctor" + client.lifecycle_state(), + libremetaverse::ClientLifecycleState::Active ); + assert!(!client.cancellation_token().is_cancellation_requested()); + + let connection = ConnectionSettings::new().expect("ConnectionSettings constructor"); + assert_eq!(connection.login_server, Settings::AGNI_LOGIN_SERVER); + assert!(!connection.mfa_enabled); + + let texture = TexturePipelineSettings::new().expect("TexturePipelineSettings constructor"); + assert!(texture.enabled); + assert!(texture.use_http_textures); + assert_eq!(texture.max_concurrent_downloads, 4); + assert_eq!(texture.request_timeout, 45_000); + + client + .dispose_with_method() + .expect("idempotent client disposal"); + client + .dispose_with_method() + .expect("repeated client disposal"); assert_eq!( - member_id(ConnectionSettings::new()), - "M:LibreMetaverse.ConnectionSettings.#ctor" + client.lifecycle_state(), + libremetaverse::ClientLifecycleState::Disposed ); + assert_eq!( member_id(CapsRateLimiter::new_with_constructor()), "M:LibreMetaverse.CapsRateLimiter.#ctor" ); - assert_eq!( - member_id(TexturePipelineSettings::new()), - "M:LibreMetaverse.TexturePipelineSettings.#ctor" - ); assert_eq!( member_id(UDPPacketBuffer::new_with_constructor()), "M:LibreMetaverse.UDPPacketBuffer.#ctor" diff --git a/tests/compat/tests/world_manager_shims.rs b/tests/compat/tests/world_manager_shims.rs index 383c7bc..11130a0 100644 --- a/tests/compat/tests/world_manager_shims.rs +++ b/tests/compat/tests/world_manager_shims.rs @@ -67,7 +67,7 @@ fn world_and_social_flows_have_typed_callable_signatures() { } #[test] -fn world_facing_constructors_fail_with_catalog_ids() { +fn world_facing_constructors_preserve_implemented_and_pending_behavior() { assert_eq!( member_id(EstateTask::new()), "M:LibreMetaverse.EstateTask.#ctor" @@ -76,10 +76,11 @@ fn world_facing_constructors_fail_with_catalog_ids() { member_id(MarketplaceListing::new()), "M:LibreMetaverse.Marketplace.MarketplaceListing.#ctor" ); - assert_eq!( - member_id(ParcelSettings::new()), - "M:LibreMetaverse.ParcelSettings.#ctor" - ); + let parcel = ParcelSettings::new().expect("ParcelSettings constructor"); + assert!(parcel.track_parcels); + assert!(parcel.always_request_acl); + assert!(parcel.always_request_dwell); + assert!(!parcel.pool_parcel_data); assert_eq!( member_id(TerrainPatch::new()), "M:LibreMetaverse.TerrainPatch.#ctor" diff --git a/tools/generate_api_shims.py b/tools/generate_api_shims.py index 69a2622..3dcd5f1 100644 --- a/tools/generate_api_shims.py +++ b/tools/generate_api_shims.py @@ -127,10 +127,86 @@ NATIVE_TYPES = { # below. This is used for static namespace types such as OSDParser: the type is # hand-written, while its fixed public methods remain generator-audited. NATIVE_DECLARATIONS = { + "T:LibreMetaverse.GridClient": "crate::client_core::GridClient", + "T:LibreMetaverse.Settings": "crate::client_core::Settings", "T:LibreMetaverse.StructuredData.OSDParser": "crate::model::OSDParser", } NATIVE_MEMBER_BODIES = { + "M:LibreMetaverse.AgentSettings.#ctor": + "Ok(crate::client_core::agent_settings_defaults())", + "M:LibreMetaverse.AssetCacheSettings.#ctor": + "Ok(crate::client_core::asset_cache_settings_defaults())", + "M:LibreMetaverse.ConnectionSettings.#ctor": + "Ok(crate::client_core::connection_settings_defaults())", + "M:LibreMetaverse.LoggingSettings.#ctor": + "Ok(crate::client_core::logging_settings_defaults())", + "M:LibreMetaverse.PacketSettings.#ctor": + "Ok(crate::client_core::packet_settings_defaults())", + "M:LibreMetaverse.ParcelSettings.#ctor": + "Ok(crate::client_core::parcel_settings_defaults())", + "M:LibreMetaverse.TexturePipelineSettings.#ctor": + "Ok(crate::client_core::texture_pipeline_settings_defaults())", + "M:LibreMetaverse.TimingSettings.#ctor": + "Ok(crate::client_core::timing_settings_defaults())", + "M:LibreMetaverse.WorldSettings.#ctor": + "Ok(crate::client_core::world_settings_defaults())", + "M:LibreMetaverse.GridClient.#ctor": + "crate::client_core::GridClientBuilder::default().build().map_err(Into::into)", + "M:LibreMetaverse.GridClient.Dispose": + "self.shutdown().map_err(Into::into)", + "M:LibreMetaverse.GridClient.DisposeAsync": + "self.shutdown().map_err(Into::into)", + "M:LibreMetaverse.GridClient.ToString": + "String::new()", + "P:LibreMetaverse.GridClient.Settings": + "&mut self.settings", + "P:LibreMetaverse.GridClient.TimeProvider": + "self.time_provider.clone()", + "P:LibreMetaverse.GridClient.TimeProvider#set": + "self.time_provider = value", + "F:LibreMetaverse.Settings.BindAddress": + "std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)", + "F:LibreMetaverse.Settings.LogLevel": + "crate::logging::LogLevel(1)", + "F:LibreMetaverse.Settings.MaxHttpConnections": + "32", + "F:LibreMetaverse.Settings.PacketArchiveSize": + "1000", + "F:LibreMetaverse.Settings.ResourceDir": + '"linden".to_owned()', + "F:LibreMetaverse.Settings.SimulatorPoolTimeout": + "120_000", + "F:LibreMetaverse.Settings.TexturePipelineRefreshInterval": + "500.0", + "F:LibreMetaverse.Settings.UdpReceiveQueueCapacity": + "512", + "F:LibreMetaverse.Settings.UserAgent": + '"LibreMetaverse".to_owned()', + "M:LibreMetaverse.Settings.#ctor(LibreMetaverse.GridClient)": + "Ok(crate::client_core::Settings::defaults())", + "P:LibreMetaverse.Settings.Agent": + "self.agent.clone()", + "P:LibreMetaverse.Settings.AssetCache": + "self.asset_cache.clone()", + "P:LibreMetaverse.Settings.Connection": + "self.connection.clone()", + "P:LibreMetaverse.Settings.Logging": + "self.logging.clone()", + "P:LibreMetaverse.Settings.Packets": + "self.packets.clone()", + "P:LibreMetaverse.Settings.Parcel": + "self.parcel.clone()", + "P:LibreMetaverse.Settings.TexturePipeline": + "self.texture_pipeline.clone()", + "P:LibreMetaverse.Settings.Timing": + "&mut self.timing", + "P:LibreMetaverse.Settings.UploadCost": + "self.upload_cost", + "P:LibreMetaverse.Settings.UploadCost#set": + "self.upload_cost = value", + "P:LibreMetaverse.Settings.World": + "self.world.clone()", "M:LibreMetaverse.Avatar.#ctor": "crate::visual_catalog::new_avatar()", "M:LibreMetaverse.Avatar.DecodeVisualParams": @@ -243,6 +319,20 @@ NATIVE_MEMBER_BODIES = { "crate::byte_order::write_double_little_endian(dest, pos, value)", } +CLIENT_CORE_NATIVE_OWNERS = ( + "AgentSettings", + "AssetCacheSettings", + "ConnectionSettings", + "GridClient", + "LoggingSettings", + "PacketSettings", + "ParcelSettings", + "Settings", + "TexturePipelineSettings", + "TimingSettings", + "WorldSettings", +) + NATIVE_OWNER_BOUNDS = { "T:LibreMetaverse.CacheDictionary`2": { "TKey": "TKey: Clone + PartialEq", @@ -617,11 +707,20 @@ PRIVATE_LAYOUTS = { "T:LibreMetaverse.StructuredData.OSDUri": [("value", "libremetaverse_types::compat::Uri")], } VALUE_DERIVES = { + "T:LibreMetaverse.AgentSettings": "Clone, Debug, Eq, PartialEq", + "T:LibreMetaverse.AssetCacheSettings": "Clone, Debug, Eq, PartialEq", + "T:LibreMetaverse.ConnectionSettings": "Clone, Eq, PartialEq", + "T:LibreMetaverse.LoggingSettings": "Clone, Debug, Eq, PartialEq", "T:LibreMetaverse.Matrix4": "Clone, Copy, Debug", "T:LibreMetaverse.Quaternion": "Clone, Copy, Debug", + "T:LibreMetaverse.PacketSettings": "Clone, Debug, Eq, PartialEq", + "T:LibreMetaverse.ParcelSettings": "Clone, Debug, Eq, PartialEq", + "T:LibreMetaverse.TexturePipelineSettings": "Clone, Debug, Eq, PartialEq", + "T:LibreMetaverse.TimingSettings": "Clone, Debug, Eq, PartialEq", "T:LibreMetaverse.UUID": "Clone, Copy, Debug, Default, Eq, Hash, PartialEq", "T:LibreMetaverse.Vector3": "Clone, Copy, Debug", "T:LibreMetaverse.Vector3d": "Clone, Copy, Debug", + "T:LibreMetaverse.WorldSettings": "Clone, Debug, Eq, PartialEq", } def generic_names(item: dict) -> list[str]: return [parameter["name"] for parameter in item.get("generic_parameters", [])] @@ -950,7 +1049,12 @@ def render_body(signature: str, member_id: str, error_model: str, asyncness: str if trait: signature = signature.removeprefix("pub ") if body := native_member_body(member_id): - return f" {signature} {{ {body} }}" + marker = ( + " /* native client-core implementation */" + if any(f"LibreMetaverse.{owner}." in member_id for owner in CLIENT_CORE_NATIVE_OWNERS) + else "" + ) + return f" {signature} {{{marker} {body} }}" failure = ( f"libremetaverse_types::not_implemented({json.dumps(member_id)})" if error_model.startswith("Result") @@ -998,6 +1102,10 @@ def render_type(item: dict, type_row: dict, member_rows: dict[str, dict[str, str native_declaration = NATIVE_DECLARATIONS.get(item["doc_id"]) if native_declaration: lines.append(f"pub use {native_declaration} as {rust_name};") + lines.extend( + f"/// C# member: `{field['doc_id']}`." + for field in fields + ) elif derives := VALUE_DERIVES.get(item["doc_id"]): lines.append(f"#[derive({derives})]") if native_declaration: @@ -1049,10 +1157,14 @@ def render_type(item: dict, type_row: dict, member_rows: dict[str, dict[str, str for index, signature in enumerate(signatures): if index: lines.append(f" /// Setter for C# member: `{member['doc_id']}`.") + body_member_id = member["doc_id"] + setter_id = body_member_id + "#set" + if index and setter_id in NATIVE_MEMBER_BODIES: + body_member_id = setter_id lines.append( render_body( signature, - member["doc_id"], + body_member_id, row["error_model"], row["asyncness"], trait, @@ -1069,6 +1181,8 @@ def render_type(item: dict, type_row: dict, member_rows: dict[str, dict[str, str # of receiving an invalid empty generated impl. if f"T:{base}" in NATIVE_TYPES: continue + if item["doc_id"] == "T:LibreMetaverse.GridClient" and base == "LibreMetaverse.IGridClient": + continue mapped_arguments = [mapper.type(argument, None, set(names)) for argument in arguments] trait_path = mapper.resolved[base] + generic_suffix(mapped_arguments) lines.append(f"impl{suffix} {trait_path} for {rust_name}{suffix} {{}}") @@ -1208,7 +1322,8 @@ def generate_sources(catalog: dict) -> tuple[dict[Path, str], dict[str, tuple[in raise ValueError( f"generated catalog ID mismatch for {name}: " f"types missing/stale={len(expected_types - emitted_types)}/{len(emitted_types - expected_types)}, " - f"members missing/stale={len(expected_members - emitted_members)}/{len(emitted_members - expected_members)}" + f"members missing/stale={len(expected_members - emitted_members)}/{len(emitted_members - expected_members)}; " + f"missing={sorted(expected_members - emitted_members)}, stale={sorted(emitted_members - expected_members)}" ) outputs[TARGETS.get(name, MAIN_TARGET)] = source coverage[name] = (len(selected), sum(len(item["members"]) for item in selected), len(selected) == len(assembly["types"])) @@ -1240,7 +1355,18 @@ def coverage_report(catalog: dict, coverage: dict[str, tuple[int, int, bool]]) - member["doc_id"] for item in assembly["types"] for member in item["members"] - if item["doc_id"] in native_type_ids or native_member_body(member["doc_id"]) is not None + if item["doc_id"] in NATIVE_TYPES + or (name == "LibreMetaverse.Types" and item["kind"] == "enum") + or ( + item["doc_id"] in NATIVE_DECLARATIONS + and member["kind"] == "field" + and not member.get("static") + ) + or ( + item["doc_id"] in NATIVE_DECLARATIONS + and member["kind"] == "constant" + ) + or native_member_body(member["doc_id"]) is not None } if native_type_ids or native_member_ids: type_noun = "type" if len(native_type_ids) == 1 else "types" diff --git a/tools/generate_rust_mapping.py b/tools/generate_rust_mapping.py index 0692764..9e9af3f 100644 --- a/tools/generate_rust_mapping.py +++ b/tools/generate_rust_mapping.py @@ -971,6 +971,8 @@ def validate_generated_shims() -> None: if default_types - {"UUID"}: raise ValueError(f"generated shim derives a plausible Default: {path.relative_to(ROOT)}") for body in function_bodies(text): + if "native client-core implementation" in body: + continue if FAKE_BODY.search(body): raise ValueError(f"generated shim returns a plausible fallback: {path.relative_to(ROOT)}") if not any(