Implement native client core lifecycle (#51)
This commit is contained in:
12
README.md
12
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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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<T> {}
|
||||
|
||||
@@ -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<AtomicBool>);
|
||||
struct CancellationState {
|
||||
cancelled: AtomicBool,
|
||||
next_id: AtomicU64,
|
||||
waiters: Mutex<BTreeMap<u64, Waker>>,
|
||||
callbacks: Mutex<BTreeMap<u64, Arc<dyn Fn() + Send + Sync>>>,
|
||||
}
|
||||
|
||||
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<CancellationState>);
|
||||
|
||||
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<dyn Fn() + Send + Sync>) -> 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<CancellationState>,
|
||||
waiter_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl Future for CancellationFuture {
|
||||
type Output = ();
|
||||
|
||||
fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
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<CancellationState>,
|
||||
id: Option<u64>,
|
||||
}
|
||||
|
||||
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<Vec<CancellationRegistration>>,
|
||||
}
|
||||
|
||||
/// Owns and triggers a [`CancellationToken`].
|
||||
#[derive(Clone)]
|
||||
pub struct CancellationTokenSource(Arc<CancellationSourceState>);
|
||||
|
||||
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<T>(pub PhantomData<fn() -> T>);
|
||||
|
||||
pub struct TaskCompletionSource<T>(pub PhantomData<fn(T)>);
|
||||
|
||||
pub struct TimeProvider;
|
||||
/// Cloneable clock boundary used by runtime-neutral client code.
|
||||
#[derive(Clone)]
|
||||
pub struct TimeProvider(Arc<dyn Fn() -> 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<F>(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(<injected clock>)")
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
833
crates/libremetaverse/src/client_core.rs
Normal file
833
crates/libremetaverse/src/client_core.rs
Normal file
@@ -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<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>>,
|
||||
shutdown_complete: Condvar,
|
||||
shutdown_wait: Mutex<()>,
|
||||
}
|
||||
|
||||
impl ClientRuntime {
|
||||
fn new(services: Vec<Arc<dyn ClientService>>) -> 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<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();
|
||||
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<ClientRuntime>,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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<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()?;
|
||||
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", &"<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]
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Self, crate::Error> {
|
||||
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<Self, crate::Error> {
|
||||
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<Self, crate::Error> {
|
||||
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<Self, crate::Error> {
|
||||
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<Self, crate::Error> {
|
||||
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<Self, crate::Error> {
|
||||
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<Self, crate::Error> {
|
||||
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<Self, crate::Error> {
|
||||
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<Self, crate::Error> {
|
||||
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<Self, crate::Error> {
|
||||
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<Self, crate::Error> {
|
||||
libremetaverse_types::not_implemented("M:LibreMetaverse.WorldSettings.#ctor")
|
||||
/* native client-core implementation */
|
||||
Ok(crate::client_core::world_settings_defaults())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
45
docs/client-core.md
Normal file
45
docs/client-core.md
Normal file
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user