feat(grid-agent): supervise grid sessions (#121)
Some checks failed
CI / rust-skia (Rust only) (push) Successful in 2m47s
CI / required (push) Failing after 2m54s

This commit is contained in:
2026-08-17 21:56:15 +00:00
parent e3b9d575f9
commit 3553c83ffa
13 changed files with 2377 additions and 36 deletions

View File

@@ -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 {