feat(grid-agent): supervise grid sessions (#121)
This commit is contained in:
@@ -7,6 +7,10 @@ use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
#[cfg(feature = "live-grid")]
|
||||
use std::sync::Arc;
|
||||
#[cfg(feature = "live-grid")]
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub type BackendFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
|
||||
@@ -82,11 +86,45 @@ impl OfflineGridBackend {
|
||||
/// Live-feature composition owner that guarantees production adapters reuse
|
||||
/// the existing `libremetaverse` manager/client graph.
|
||||
#[cfg(feature = "live-grid")]
|
||||
#[derive(Debug)]
|
||||
pub struct LibremetaverseClientOwner {
|
||||
client: libremetaverse::GridClient,
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
impl fmt::Debug for LibremetaverseClientOwner {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("LibremetaverseClientOwner")
|
||||
.field("client", &"[NATIVE CLIENT REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature-gated session adapter that reuses the existing native
|
||||
/// `NetworkManager` login, event, disconnect, and logout lifecycle.
|
||||
#[cfg(feature = "live-grid")]
|
||||
#[derive(Clone)]
|
||||
pub struct LibremetaverseSessionBackend {
|
||||
network: libremetaverse::NetworkManager,
|
||||
login_url: String,
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
password: crate::config::SecretString,
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
impl fmt::Debug for LibremetaverseSessionBackend {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("LibremetaverseSessionBackend")
|
||||
.field("transport_connected", &self.network.native_connected())
|
||||
.field("login_url", &"[REDACTED ENDPOINT]")
|
||||
.field("avatar", &"[REDACTED IDENTITY]")
|
||||
.field("password", &self.password)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
impl LibremetaverseClientOwner {
|
||||
/// Builds the shared client composition root without starting login or I/O.
|
||||
@@ -107,6 +145,213 @@ impl LibremetaverseClientOwner {
|
||||
pub const fn client(&self) -> &libremetaverse::GridClient {
|
||||
&self.client
|
||||
}
|
||||
|
||||
/// Creates the supervised live-session adapter without logging in.
|
||||
pub fn session_backend(
|
||||
&self,
|
||||
connection: crate::config::GridConnection,
|
||||
) -> Result<LibremetaverseSessionBackend, BackendError> {
|
||||
let avatar = connection.avatar_name.trim();
|
||||
let (first_name, last_name) = avatar
|
||||
.split_once(char::is_whitespace)
|
||||
.map_or((avatar, "Resident"), |(first, last)| (first, last.trim()));
|
||||
if first_name.is_empty() || last_name.is_empty() {
|
||||
return Err(BackendError::Configuration {
|
||||
component: "grid avatar identity",
|
||||
});
|
||||
}
|
||||
Ok(LibremetaverseSessionBackend {
|
||||
network: self.client.network(),
|
||||
login_url: connection.login_url.expose_url().to_owned(),
|
||||
first_name: first_name.to_owned(),
|
||||
last_name: last_name.to_owned(),
|
||||
password: connection.password,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
struct LibremetaverseSession {
|
||||
generation: u64,
|
||||
network: libremetaverse::NetworkManager,
|
||||
signals: mpsc::Receiver<crate::session::SessionSignal>,
|
||||
subscriptions: Vec<libremetaverse_types::compat::Subscription>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
impl crate::session::GridSession for LibremetaverseSession {
|
||||
fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
fn next_signal(
|
||||
&mut self,
|
||||
cancellation: CancellationToken,
|
||||
) -> crate::session::SessionFuture<'_, crate::session::SessionSignal> {
|
||||
Box::pin(async move {
|
||||
tokio::select! {
|
||||
signal = self.signals.recv() => signal.unwrap_or_else(|| {
|
||||
crate::session::SessionSignal::Disconnected(
|
||||
crate::session::SessionFailure::new(
|
||||
crate::session::SessionFailureKind::TransientTransport,
|
||||
),
|
||||
)
|
||||
}),
|
||||
() = cancellation.cancelled() => crate::session::SessionSignal::Disconnected(
|
||||
crate::session::SessionFailure::new(
|
||||
crate::session::SessionFailureKind::TransientTransport,
|
||||
),
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn logout(
|
||||
mut self: Box<Self>,
|
||||
cancellation: CancellationToken,
|
||||
) -> crate::session::SessionFuture<'static, Result<(), crate::session::SessionFailure>> {
|
||||
Box::pin(async move {
|
||||
self.subscriptions.clear();
|
||||
self.network
|
||||
.native_logout_async(Some(cancellation))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
crate::session::SessionFailure::new(
|
||||
crate::session::SessionFailureKind::TransientTransport,
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
impl crate::session::GridSessionBackend for LibremetaverseSessionBackend {
|
||||
fn login(
|
||||
&self,
|
||||
generation: u64,
|
||||
cancellation: CancellationToken,
|
||||
) -> crate::session::SessionFuture<
|
||||
'_,
|
||||
Result<Box<dyn crate::session::GridSession>, crate::session::SessionFailure>,
|
||||
> {
|
||||
Box::pin(async move {
|
||||
let mut params = self
|
||||
.network
|
||||
.native_default_login_params(
|
||||
self.first_name.clone(),
|
||||
self.last_name.clone(),
|
||||
self.password.expose_secret().to_owned(),
|
||||
"MetaCrate".to_owned(),
|
||||
env!("CARGO_PKG_VERSION").to_owned(),
|
||||
)
|
||||
.map_err(|_| {
|
||||
crate::session::SessionFailure::new(
|
||||
crate::session::SessionFailureKind::InvalidConfiguration,
|
||||
)
|
||||
})?;
|
||||
params.uri.clone_from(&self.login_url);
|
||||
params.start = "last".to_owned();
|
||||
let logged_in = self
|
||||
.network
|
||||
.native_login(params, Some(cancellation))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
crate::session::SessionFailure::new(
|
||||
crate::session::SessionFailureKind::TransientTransport,
|
||||
)
|
||||
})?;
|
||||
if !logged_in {
|
||||
return Err(classify_native_login_failure(
|
||||
&self.network.native_login_error_key(),
|
||||
));
|
||||
}
|
||||
|
||||
// Native login has already installed the current simulator and
|
||||
// parsed inventory state. The existing GridClient managers retain
|
||||
// world/inventory and movement-worker ownership; this generation
|
||||
// adds only one disconnect and one readiness subscription.
|
||||
let (sender, signals) = mpsc::channel(8);
|
||||
let disconnected_sender = sender.clone();
|
||||
let disconnected = self
|
||||
.network
|
||||
.native_subscribe_disconnected(Arc::new(move |event| {
|
||||
let kind = match event.reason() {
|
||||
libremetaverse::NetworkManagerDisconnectType::NetworkTimeout => {
|
||||
crate::session::SessionFailureKind::TransientTransport
|
||||
}
|
||||
libremetaverse::NetworkManagerDisconnectType::ServerInitiated => {
|
||||
crate::session::SessionFailureKind::Kicked
|
||||
}
|
||||
libremetaverse::NetworkManagerDisconnectType::SimShutdown => {
|
||||
crate::session::SessionFailureKind::Maintenance
|
||||
}
|
||||
libremetaverse::NetworkManagerDisconnectType::ClientInitiated => {
|
||||
crate::session::SessionFailureKind::TransientTransport
|
||||
}
|
||||
};
|
||||
let _ =
|
||||
disconnected_sender.try_send(crate::session::SessionSignal::Disconnected(
|
||||
crate::session::SessionFailure::new(kind),
|
||||
));
|
||||
}));
|
||||
let ready_sender = sender.clone();
|
||||
let ready_once = Arc::new(AtomicBool::new(false));
|
||||
let callback_ready = Arc::clone(&ready_once);
|
||||
let ready = self
|
||||
.network
|
||||
.native_subscribe_event_queue_running(Arc::new(move |_| {
|
||||
if !callback_ready.swap(true, Ordering::AcqRel) {
|
||||
let _ = ready_sender.try_send(crate::session::SessionSignal::Ready);
|
||||
}
|
||||
}));
|
||||
if !self.network.native_connected() {
|
||||
let _ = sender.try_send(crate::session::SessionSignal::Disconnected(
|
||||
crate::session::SessionFailure::new(
|
||||
crate::session::SessionFailureKind::TransientTransport,
|
||||
),
|
||||
));
|
||||
} else if self
|
||||
.network
|
||||
.native_current_sim()
|
||||
.and_then(|simulator| simulator.native_is_event_queue_running(None).ok())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if !ready_once.swap(true, Ordering::AcqRel) {
|
||||
let _ = sender.try_send(crate::session::SessionSignal::Ready);
|
||||
}
|
||||
}
|
||||
let session: Box<dyn crate::session::GridSession> = Box::new(LibremetaverseSession {
|
||||
generation,
|
||||
network: self.network.clone(),
|
||||
signals,
|
||||
subscriptions: vec![disconnected, ready],
|
||||
});
|
||||
Ok(session)
|
||||
})
|
||||
}
|
||||
|
||||
fn flush_audit(
|
||||
&self,
|
||||
_cancellation: CancellationToken,
|
||||
) -> crate::session::SessionFuture<'_, Result<(), crate::session::SessionFailure>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "live-grid")]
|
||||
fn classify_native_login_failure(error_key: &str) -> crate::session::SessionFailure {
|
||||
let normalized = error_key.trim().to_ascii_lowercase();
|
||||
let kind = if matches!(
|
||||
normalized.as_str(),
|
||||
"key" | "password" | "credential" | "account" | "username" | "user"
|
||||
) {
|
||||
crate::session::SessionFailureKind::InvalidCredentials
|
||||
} else if normalized == "canceled" {
|
||||
crate::session::SessionFailureKind::TransientTransport
|
||||
} else {
|
||||
crate::session::SessionFailureKind::ServerFailure
|
||||
};
|
||||
crate::session::SessionFailure::new(kind)
|
||||
}
|
||||
|
||||
impl GridBackend for OfflineGridBackend {
|
||||
|
||||
Reference in New Issue
Block a user