Files
MetaCrate/crates/metacrate-grid-agent/src/session.rs
Chili Palmer 33d1cf6827
Some checks failed
CI / rust-skia (Rust only) (push) Has been cancelled
CI / required (push) Has been cancelled
Centralize world and viewport game loops
2026-08-23 13:42:01 +02:00

1132 lines
39 KiB
Rust

//! Supervised, generation-fenced grid-session lifecycle.
#![allow(clippy::missing_errors_doc)]
use crate::types::{BoundedText, MAX_IDENTIFIER_BYTES};
use libremetaverse_types::compat::{CancellationToken, CancellationTokenSource};
use std::collections::{BTreeSet, VecDeque};
use std::error::Error;
use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::{mpsc, oneshot, watch};
use tokio::task::JoinHandle;
use tokio::time::Instant;
const MAX_OFFLINE_WORK: usize = 1_024;
const MAX_SEEN_WORK: usize = 4_096;
const MAX_BACKOFF: Duration = Duration::from_hours(1);
const MAX_STABLE_RESET: Duration = Duration::from_hours(24);
const MAX_READINESS_TIMEOUT: Duration = Duration::from_mins(10);
const MAX_SHUTDOWN: Duration = Duration::from_mins(1);
pub type SessionFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
/// Generation-scoped gate between transport readiness and autonomous agent readiness.
pub struct FrameworkReadiness {
generation: watch::Sender<u64>,
}
impl Default for FrameworkReadiness {
fn default() -> Self {
let (generation, _) = watch::channel(0);
Self { generation }
}
}
impl FrameworkReadiness {
pub fn mark_ready(&self, generation: u64) {
if generation != 0 {
self.generation.send_replace(generation);
}
}
pub async fn wait_ready(&self, generation: u64, cancellation: CancellationToken) -> bool {
let mut ready = self.generation.subscribe();
loop {
if *ready.borrow() == generation {
return true;
}
tokio::select! {
() = cancellation.cancelled() => return false,
changed = ready.changed() => if changed.is_err() { return false; },
}
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum SessionState {
Stopped = 0,
Connecting = 1,
Online = 2,
Degraded = 3,
Backoff = 4,
AuthenticationBlocked = 5,
Paused = 6,
ShuttingDown = 7,
}
impl SessionState {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Stopped => "stopped",
Self::Connecting => "connecting",
Self::Online => "online",
Self::Degraded => "degraded",
Self::Backoff => "backoff",
Self::AuthenticationBlocked => "authentication-blocked",
Self::Paused => "paused",
Self::ShuttingDown => "shutting-down",
}
}
const fn from_u8(value: u8) -> Self {
match value {
1 => Self::Connecting,
2 => Self::Online,
3 => Self::Degraded,
4 => Self::Backoff,
5 => Self::AuthenticationBlocked,
6 => Self::Paused,
7 => Self::ShuttingDown,
_ => Self::Stopped,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SessionReason {
Startup,
LoginSucceeded,
AgentReady,
ReadinessLost,
TransientTransport,
Maintenance,
Kicked,
SimulatorDisconnected,
ServerFailure,
InvalidCredentials,
InvalidConfiguration,
StableSessionReset,
OperatorPause,
OperatorResume,
OperatorForceReconnect,
OperatorLogout,
ShutdownRequested,
ShutdownComplete,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SessionFailureKind {
InvalidCredentials,
InvalidConfiguration,
TransientTransport,
Maintenance,
Kicked,
SimulatorDisconnected,
ServerFailure,
}
/// Secret-free failure classification. Raw login/server text is deliberately
/// not representable at this boundary.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SessionFailure {
kind: SessionFailureKind,
retry_after: Option<Duration>,
}
impl SessionFailure {
#[must_use]
pub const fn new(kind: SessionFailureKind) -> Self {
Self {
kind,
retry_after: None,
}
}
#[must_use]
pub const fn with_retry_after(kind: SessionFailureKind, retry_after: Duration) -> Self {
Self {
kind,
retry_after: Some(retry_after),
}
}
#[must_use]
pub const fn kind(self) -> SessionFailureKind {
self.kind
}
#[must_use]
pub const fn retry_after(self) -> Option<Duration> {
self.retry_after
}
const fn retryable(self) -> bool {
!matches!(
self.kind,
SessionFailureKind::InvalidCredentials | SessionFailureKind::InvalidConfiguration
)
}
const fn reason(self) -> SessionReason {
match self.kind {
SessionFailureKind::InvalidCredentials => SessionReason::InvalidCredentials,
SessionFailureKind::InvalidConfiguration => SessionReason::InvalidConfiguration,
SessionFailureKind::TransientTransport => SessionReason::TransientTransport,
SessionFailureKind::Maintenance => SessionReason::Maintenance,
SessionFailureKind::Kicked => SessionReason::Kicked,
SessionFailureKind::SimulatorDisconnected => SessionReason::SimulatorDisconnected,
SessionFailureKind::ServerFailure => SessionReason::ServerFailure,
}
}
}
impl fmt::Display for SessionFailure {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "grid session failure: {:?}", self.kind)
}
}
impl Error for SessionFailure {}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SessionSignal {
Ready,
Degraded,
Disconnected(SessionFailure),
}
/// One connected generation. Dropping its subscriptions and joining its
/// workers is the responsibility of `logout`, which the supervisor calls once.
pub trait GridSession: Send {
fn generation(&self) -> u64;
fn next_signal(&mut self, cancellation: CancellationToken) -> SessionFuture<'_, SessionSignal>;
fn logout(
self: Box<Self>,
cancellation: CancellationToken,
) -> SessionFuture<'static, Result<(), SessionFailure>>;
}
/// Injectable login owner. A production implementation must reuse the native
/// `NetworkManager`; deterministic tests supply a fake implementation.
pub trait GridSessionBackend: Send + Sync + 'static {
fn login(
&self,
generation: u64,
cancellation: CancellationToken,
) -> SessionFuture<'_, Result<Box<dyn GridSession>, SessionFailure>>;
fn flush_audit(
&self,
cancellation: CancellationToken,
) -> SessionFuture<'_, Result<(), SessionFailure>>;
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ReconnectPolicy {
pub initial_delay: Duration,
pub maximum_delay: Duration,
pub readiness_timeout: Duration,
pub stable_reset_after: Duration,
pub shutdown_deadline: Duration,
pub jitter_basis_points: u16,
pub instance_seed: u64,
pub offline_work_capacity: usize,
}
impl Default for ReconnectPolicy {
fn default() -> Self {
Self {
initial_delay: Duration::from_secs(1),
maximum_delay: Duration::from_mins(1),
readiness_timeout: Duration::from_mins(2),
stable_reset_after: Duration::from_mins(2),
shutdown_deadline: Duration::from_secs(10),
jitter_basis_points: 2_000,
instance_seed: runtime_jitter_seed(),
offline_work_capacity: 128,
}
}
}
fn runtime_jitter_seed() -> u64 {
static NEXT_SEED: AtomicU64 = AtomicU64::new(1);
let time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| {
u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX)
});
mix64(time ^ u64::from(std::process::id()) ^ NEXT_SEED.fetch_add(1, Ordering::Relaxed))
}
impl ReconnectPolicy {
pub fn validate(self) -> Result<Self, SessionSupervisorError> {
if self.initial_delay.is_zero()
|| self.initial_delay > self.maximum_delay
|| self.maximum_delay > MAX_BACKOFF
|| self.readiness_timeout.is_zero()
|| self.readiness_timeout > MAX_READINESS_TIMEOUT
|| self.stable_reset_after.is_zero()
|| self.stable_reset_after > MAX_STABLE_RESET
|| self.shutdown_deadline.is_zero()
|| self.shutdown_deadline > MAX_SHUTDOWN
|| self.jitter_basis_points > 5_000
|| !(1..=MAX_OFFLINE_WORK).contains(&self.offline_work_capacity)
{
return Err(SessionSupervisorError::UnsafePolicy);
}
Ok(self)
}
#[must_use]
pub fn retry_delay(self, failure_count: u32, hint: Option<Duration>) -> Duration {
let shift = failure_count.saturating_sub(1).min(31);
let factor = 1_u128 << shift;
let base_ms = self.initial_delay.as_millis().saturating_mul(factor);
let cap_ms = self.maximum_delay.as_millis();
let hinted_ms = hint.map_or(0, |value| value.as_millis().min(cap_ms));
let unclamped = base_ms.max(hinted_ms).min(cap_ms);
let spread = unclamped.saturating_mul(u128::from(self.jitter_basis_points)) / 10_000;
let random = mix64(self.instance_seed ^ u64::from(failure_count));
let width = spread.saturating_mul(2).saturating_add(1);
let offset = if width == 0 {
0
} else {
u128::from(random) % width
};
let jittered = unclamped
.saturating_sub(spread)
.saturating_add(offset)
.max(hinted_ms);
Duration::from_millis(u64::try_from(jittered.min(cap_ms)).unwrap_or(u64::MAX))
}
}
fn mix64(mut value: u64) -> u64 {
value ^= value >> 30;
value = value.wrapping_mul(0xbf58_476d_1ce4_e5b9);
value ^= value >> 27;
value = value.wrapping_mul(0x94d0_49bb_1331_11eb);
value ^ (value >> 31)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SessionStatus {
pub state: SessionState,
pub generation: u64,
pub transport_connected: bool,
pub agent_ready: bool,
pub consecutive_failures: u32,
}
impl Default for SessionStatus {
fn default() -> Self {
Self {
state: SessionState::Stopped,
generation: 0,
transport_connected: false,
agent_ready: false,
consecutive_failures: 0,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WorkKind {
ReadOnly,
IdempotentMutation,
NonIdempotentMutation,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SessionWork {
pub id: BoundedText<MAX_IDENTIFIER_BYTES>,
pub kind: WorkKind,
}
impl SessionWork {
pub fn new(id: impl Into<String>, kind: WorkKind) -> Result<Self, SessionSupervisorError> {
Ok(Self {
id: BoundedText::new("session.work_id", id)
.map_err(|_| SessionSupervisorError::InvalidWork)?,
kind,
})
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WorkDisposition {
Accepted { generation: u64 },
Queued,
RejectedOfflineMutation,
RejectedQueueFull,
RejectedDuplicate,
RejectedShuttingDown,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SessionObservation {
Transition {
status: SessionStatus,
reason: SessionReason,
retry_in: Option<Duration>,
},
Work {
id: BoundedText<MAX_IDENTIFIER_BYTES>,
disposition: WorkDisposition,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SessionControl {
Pause,
Resume,
ForceReconnect,
Logout,
Shutdown,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SessionSupervisorError {
UnsafePolicy,
InvalidWork,
ControlClosed,
ObservationClosed,
TaskPanicked,
ShutdownTimedOut,
}
impl fmt::Display for SessionSupervisorError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnsafePolicy => formatter.write_str("unsafe reconnect policy"),
Self::InvalidWork => formatter.write_str("invalid session work envelope"),
Self::ControlClosed => formatter.write_str("session control queue closed"),
Self::ObservationClosed => formatter.write_str("session observation queue closed"),
Self::TaskPanicked => formatter.write_str("session supervisor task panicked"),
Self::ShutdownTimedOut => formatter.write_str("session shutdown deadline exceeded"),
}
}
}
impl Error for SessionSupervisorError {}
enum Command {
Control(SessionControl),
Submit(SessionWork, oneshot::Sender<WorkDisposition>),
}
#[derive(Debug)]
struct GenerationFence {
generation: AtomicU64,
source: Mutex<Option<CancellationTokenSource>>,
}
impl GenerationFence {
fn rotate(&self, generation: u64) -> CancellationToken {
let mut source = lock(&self.source);
if let Some(previous) = source.take() {
previous.cancel();
}
let current = CancellationTokenSource::new();
let token = current.token();
*source = Some(current);
self.generation.store(generation, Ordering::Release);
token
}
fn invalidate(&self) {
if let Some(source) = lock(&self.source).take() {
source.cancel();
}
self.generation.store(0, Ordering::Release);
}
fn token(&self, generation: u64) -> Option<CancellationToken> {
if self.generation.load(Ordering::Acquire) != generation {
return None;
}
lock(&self.source)
.as_ref()
.map(CancellationTokenSource::token)
}
}
pub struct SessionSupervisor {
backend: Arc<dyn GridSessionBackend>,
policy: ReconnectPolicy,
control_capacity: usize,
observation_capacity: usize,
}
impl SessionSupervisor {
pub fn new(
backend: Arc<dyn GridSessionBackend>,
policy: ReconnectPolicy,
control_capacity: usize,
observation_capacity: usize,
) -> Result<Self, SessionSupervisorError> {
let policy = policy.validate()?;
if control_capacity == 0
|| control_capacity > 256
|| observation_capacity == 0
|| observation_capacity > 8_192
{
return Err(SessionSupervisorError::UnsafePolicy);
}
Ok(Self {
backend,
policy,
control_capacity,
observation_capacity,
})
}
#[must_use]
pub fn start(self) -> SessionSupervisorHandle {
let (commands, receiver) = mpsc::channel(self.control_capacity);
let (observations, observation_receiver) = mpsc::channel(self.observation_capacity);
let cancellation = CancellationTokenSource::new();
let state = Arc::new(AtomicU8::new(SessionState::Stopped as u8));
let status = Arc::new(Mutex::new(SessionStatus::default()));
let fence = Arc::new(GenerationFence {
generation: AtomicU64::new(0),
source: Mutex::new(None),
});
let runtime = Runtime {
backend: self.backend,
policy: self.policy,
commands: receiver,
observations,
cancellation: cancellation.clone(),
state: Arc::clone(&state),
status: Arc::clone(&status),
fence: Arc::clone(&fence),
queued: VecDeque::new(),
queued_ids: BTreeSet::new(),
seen: VecDeque::new(),
seen_ids: BTreeSet::new(),
next_generation: 1,
failures: 0,
};
let task = tokio::spawn(runtime.run());
SessionSupervisorHandle {
commands,
observations: observation_receiver,
cancellation,
state,
status,
fence,
task: Some(task),
shutdown_deadline: self.policy.shutdown_deadline,
}
}
}
pub struct SessionSupervisorHandle {
commands: mpsc::Sender<Command>,
observations: mpsc::Receiver<SessionObservation>,
cancellation: CancellationTokenSource,
state: Arc<AtomicU8>,
status: Arc<Mutex<SessionStatus>>,
fence: Arc<GenerationFence>,
task: Option<JoinHandle<()>>,
shutdown_deadline: Duration,
}
impl SessionSupervisorHandle {
#[must_use]
pub fn state(&self) -> SessionState {
SessionState::from_u8(self.state.load(Ordering::Acquire))
}
#[must_use]
pub fn status(&self) -> SessionStatus {
*lock(&self.status)
}
pub async fn control(&self, control: SessionControl) -> Result<(), SessionSupervisorError> {
self.commands
.send(Command::Control(control))
.await
.map_err(|_| SessionSupervisorError::ControlClosed)
}
pub async fn submit(
&self,
work: SessionWork,
) -> Result<WorkDisposition, SessionSupervisorError> {
let (sender, receiver) = oneshot::channel();
self.commands
.send(Command::Submit(work, sender))
.await
.map_err(|_| SessionSupervisorError::ControlClosed)?;
receiver
.await
.map_err(|_| SessionSupervisorError::ControlClosed)
}
pub async fn next_observation(&mut self) -> Option<SessionObservation> {
self.observations.recv().await
}
/// Returns the cancellation token shared by inference, scheduled work, and
/// tools for exactly one live generation.
#[must_use]
pub fn generation_token(&self, generation: u64) -> Option<CancellationToken> {
self.fence.token(generation)
}
/// Late LLM/tool results are accepted only while their generation is fully ready.
#[must_use]
pub fn accepts_result(&self, generation: u64) -> bool {
let status = self.status();
status.agent_ready && status.generation == generation
}
pub async fn shutdown(&mut self) -> Result<(), SessionSupervisorError> {
self.state
.store(SessionState::ShuttingDown as u8, Ordering::Release);
self.cancellation.cancel();
let Some(task) = self.task.as_mut() else {
return Ok(());
};
match tokio::time::timeout(self.shutdown_deadline, &mut *task).await {
Ok(Ok(())) => {
self.task = None;
Ok(())
}
Ok(Err(_)) => {
self.task = None;
Err(SessionSupervisorError::TaskPanicked)
}
Err(_) => {
task.abort();
let _ = (&mut *task).await;
self.task = None;
Err(SessionSupervisorError::ShutdownTimedOut)
}
}
}
}
impl Drop for SessionSupervisorHandle {
fn drop(&mut self) {
self.cancellation.cancel();
if let Some(task) = &self.task {
task.abort();
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DesiredState {
Running,
Paused,
LoggedOut,
AuthenticationBlocked,
Shutdown,
}
enum LoginEvent {
Cancelled,
Command(Option<Command>),
Complete(Result<Box<dyn GridSession>, SessionFailure>),
}
enum ActiveEvent {
Cancelled,
Command(Option<Command>),
ReadinessTimedOut,
Signal(SessionSignal),
}
struct Runtime {
backend: Arc<dyn GridSessionBackend>,
policy: ReconnectPolicy,
commands: mpsc::Receiver<Command>,
observations: mpsc::Sender<SessionObservation>,
cancellation: CancellationTokenSource,
state: Arc<AtomicU8>,
status: Arc<Mutex<SessionStatus>>,
fence: Arc<GenerationFence>,
queued: VecDeque<SessionWork>,
queued_ids: BTreeSet<String>,
seen: VecDeque<String>,
seen_ids: BTreeSet<String>,
next_generation: u64,
failures: u32,
}
impl Runtime {
#[allow(clippy::too_many_lines)]
async fn run(mut self) {
self.transition(SessionState::Stopped, SessionReason::Startup, None, 0);
let mut desired = DesiredState::Running;
let mut session: Option<Box<dyn GridSession>> = None;
let mut ready = false;
let mut readiness_deadline = None;
let mut stable_since = None;
loop {
if self.cancellation.token().is_cancellation_requested() {
desired = DesiredState::Shutdown;
}
match desired {
DesiredState::Shutdown => break,
DesiredState::Paused
| DesiredState::LoggedOut
| DesiredState::AuthenticationBlocked => {
let command = tokio::select! {
() = self.cancellation.token().cancelled() => {
desired = DesiredState::Shutdown;
continue;
}
command = self.commands.recv() => command,
};
desired = self.handle_idle_command(command, desired);
}
DesiredState::Running if session.is_none() => {
if self.next_generation == u64::MAX {
desired = DesiredState::Shutdown;
continue;
}
let generation = self.next_generation;
self.next_generation += 1;
let token = self.fence.rotate(generation);
self.transition(
SessionState::Connecting,
if self.failures == 0 {
SessionReason::OperatorResume
} else {
SessionReason::TransientTransport
},
None,
generation,
);
let login_event = {
let backend = Arc::clone(&self.backend);
let login = backend.login(generation, token);
tokio::pin!(login);
loop {
let event = tokio::select! {
() = self.cancellation.token().cancelled() => LoginEvent::Cancelled,
command = self.commands.recv() => LoginEvent::Command(command),
result = &mut login => LoginEvent::Complete(result),
};
if let LoginEvent::Command(Some(Command::Submit(work, response))) =
event
{
let disposition = self.handle_work(work.clone(), false, 0);
let _ = response.send(disposition);
self.emit_work(work.id, disposition);
} else {
break event;
}
}
};
let result = match login_event {
LoginEvent::Cancelled => {
desired = DesiredState::Shutdown;
self.fence.invalidate();
continue;
}
LoginEvent::Command(command) => {
self.fence.invalidate();
desired = self.handle_idle_command(command, DesiredState::Running);
continue;
}
LoginEvent::Complete(result) => result,
};
match result {
Ok(connected) if connected.generation() == generation => {
session = Some(connected);
ready = false;
readiness_deadline =
Some(Instant::now() + self.policy.readiness_timeout);
stable_since = None;
self.transition(
SessionState::Degraded,
SessionReason::LoginSucceeded,
None,
generation,
);
}
Ok(connected) => {
self.fence.invalidate();
self.close_session(connected).await;
self.failures = self.failures.saturating_add(1);
desired = self
.backoff(SessionFailure::new(SessionFailureKind::ServerFailure))
.await;
}
Err(failure) if !failure.retryable() => {
self.fence.invalidate();
self.transition(
SessionState::AuthenticationBlocked,
failure.reason(),
None,
generation,
);
desired = DesiredState::AuthenticationBlocked;
}
Err(failure) => {
self.fence.invalidate();
self.failures = self.failures.saturating_add(1);
desired = self.backoff(failure).await;
}
}
}
DesiredState::Running => {
let generation = self.status_snapshot().generation;
let Some(token) = self.fence.token(generation) else {
session = None;
continue;
};
let active_event = {
let Some(active) = session.as_mut() else {
continue;
};
let signal = active.next_signal(token);
tokio::pin!(signal);
let readiness = async {
if let Some(deadline) = readiness_deadline {
tokio::time::sleep_until(deadline).await;
} else {
std::future::pending::<()>().await;
}
};
tokio::pin!(readiness);
tokio::select! {
() = self.cancellation.token().cancelled() => ActiveEvent::Cancelled,
command = self.commands.recv() => ActiveEvent::Command(command),
() = &mut readiness => ActiveEvent::ReadinessTimedOut,
next = &mut signal => ActiveEvent::Signal(next),
}
};
match active_event {
ActiveEvent::Cancelled => {
desired = DesiredState::Shutdown;
}
ActiveEvent::ReadinessTimedOut => {
ready = false;
readiness_deadline = None;
stable_since = None;
self.fence.invalidate();
if let Some(unready) = session.take() {
self.close_session(unready).await;
}
self.failures = self.failures.saturating_add(1);
desired = self
.backoff(SessionFailure::new(
SessionFailureKind::TransientTransport,
))
.await;
}
ActiveEvent::Command(command) => match command {
Some(Command::Submit(work, response)) => {
let disposition = self.handle_work(work.clone(), ready, generation);
let _ = response.send(disposition);
self.emit_work(work.id, disposition);
}
None => {
let (next, reconnect) = self.handle_active_control(None);
desired = next;
if reconnect {
self.failures = 0;
}
if desired != DesiredState::Running || reconnect {
self.fence.invalidate();
}
}
Some(Command::Control(control)) => {
let (next, reconnect) = self.handle_active_control(Some(control));
desired = next;
if reconnect {
self.failures = 0;
}
if desired != DesiredState::Running || reconnect {
self.fence.invalidate();
}
}
},
ActiveEvent::Signal(next) => match next {
SessionSignal::Ready => {
ready = true;
readiness_deadline = None;
stable_since.get_or_insert_with(Instant::now);
self.transition(
SessionState::Online,
SessionReason::AgentReady,
None,
generation,
);
self.release_queued(generation);
}
SessionSignal::Degraded => {
ready = false;
readiness_deadline =
Some(Instant::now() + self.policy.readiness_timeout);
stable_since = None;
self.transition(
SessionState::Degraded,
SessionReason::ReadinessLost,
None,
generation,
);
}
SessionSignal::Disconnected(failure) => {
ready = false;
self.fence.invalidate();
if stable_since.is_some_and(|start| {
start.elapsed() >= self.policy.stable_reset_after
}) {
self.failures = 0;
self.transition(
SessionState::Degraded,
SessionReason::StableSessionReset,
None,
generation,
);
}
self.failures = self.failures.saturating_add(1);
if let Some(disconnected) = session.take() {
self.close_session(disconnected).await;
}
desired = self.backoff(failure).await;
stable_since = None;
}
},
}
if session.is_some()
&& (desired != DesiredState::Running
|| self.fence.token(generation).is_none())
{
if let Some(active) = session.take() {
self.close_session(active).await;
}
ready = false;
readiness_deadline = None;
stable_since = None;
}
}
}
}
self.transition(
SessionState::ShuttingDown,
SessionReason::ShutdownRequested,
None,
self.status_snapshot().generation,
);
self.fence.invalidate();
if let Some(active) = session.take() {
self.close_session(active).await;
}
self.reject_queued();
let cleanup = CancellationTokenSource::new();
let _ = tokio::time::timeout(
self.cleanup_timeout(),
self.backend.flush_audit(cleanup.token()),
)
.await;
cleanup.cancel();
self.transition(
SessionState::Stopped,
SessionReason::ShutdownComplete,
None,
0,
);
}
fn handle_idle_command(
&mut self,
command: Option<Command>,
current: DesiredState,
) -> DesiredState {
match command {
None | Some(Command::Control(SessionControl::Shutdown)) => DesiredState::Shutdown,
Some(Command::Control(SessionControl::Pause)) => {
self.transition(SessionState::Paused, SessionReason::OperatorPause, None, 0);
DesiredState::Paused
}
Some(Command::Control(SessionControl::Resume | SessionControl::ForceReconnect)) => {
self.failures = 0;
DesiredState::Running
}
Some(Command::Control(SessionControl::Logout)) => {
self.transition(
SessionState::Stopped,
SessionReason::OperatorLogout,
None,
0,
);
DesiredState::LoggedOut
}
Some(Command::Submit(work, response)) => {
let disposition = self.handle_work(work.clone(), false, 0);
let _ = response.send(disposition);
self.emit_work(work.id, disposition);
current
}
}
}
fn handle_active_control(&mut self, command: Option<SessionControl>) -> (DesiredState, bool) {
match command {
None | Some(SessionControl::Shutdown) => (DesiredState::Shutdown, false),
Some(SessionControl::Pause) => {
self.transition(SessionState::Paused, SessionReason::OperatorPause, None, 0);
(DesiredState::Paused, false)
}
Some(SessionControl::Resume) => (DesiredState::Running, false),
Some(SessionControl::ForceReconnect) => {
self.transition(
SessionState::Connecting,
SessionReason::OperatorForceReconnect,
None,
0,
);
(DesiredState::Running, true)
}
Some(SessionControl::Logout) => {
self.transition(
SessionState::Stopped,
SessionReason::OperatorLogout,
None,
0,
);
(DesiredState::LoggedOut, false)
}
}
}
async fn backoff(&mut self, failure: SessionFailure) -> DesiredState {
let delay = self
.policy
.retry_delay(self.failures, failure.retry_after());
self.transition(SessionState::Backoff, failure.reason(), Some(delay), 0);
let sleep = tokio::time::sleep(delay);
tokio::pin!(sleep);
loop {
tokio::select! {
() = self.cancellation.token().cancelled() => return DesiredState::Shutdown,
() = &mut sleep => return DesiredState::Running,
command = self.commands.recv() => match command {
Some(Command::Submit(work, response)) => {
let disposition = self.handle_work(work.clone(), false, 0);
let _ = response.send(disposition);
self.emit_work(work.id, disposition);
}
other => return self.handle_idle_command(other, DesiredState::Running),
}
}
}
}
async fn close_session(&self, session: Box<dyn GridSession>) {
let cleanup = CancellationTokenSource::new();
let _ = tokio::time::timeout(self.cleanup_timeout(), session.logout(cleanup.token())).await;
cleanup.cancel();
}
fn cleanup_timeout(&self) -> Duration {
(self.policy.shutdown_deadline / 3).max(Duration::from_millis(1))
}
fn handle_work(&mut self, work: SessionWork, ready: bool, generation: u64) -> WorkDisposition {
if self.cancellation.token().is_cancellation_requested() {
return WorkDisposition::RejectedShuttingDown;
}
if self.seen_ids.contains(work.id.as_str()) || self.queued_ids.contains(work.id.as_str()) {
return WorkDisposition::RejectedDuplicate;
}
if ready {
self.remember_work(work.id.as_str().to_owned());
return WorkDisposition::Accepted { generation };
}
if work.kind != WorkKind::ReadOnly {
return WorkDisposition::RejectedOfflineMutation;
}
if self.queued.len() == self.policy.offline_work_capacity {
return WorkDisposition::RejectedQueueFull;
}
self.queued_ids.insert(work.id.as_str().to_owned());
self.queued.push_back(work);
WorkDisposition::Queued
}
fn release_queued(&mut self, generation: u64) {
while let Some(work) = self.queued.pop_front() {
self.queued_ids.remove(work.id.as_str());
self.remember_work(work.id.as_str().to_owned());
self.emit_work(work.id, WorkDisposition::Accepted { generation });
}
}
fn remember_work(&mut self, id: String) {
if self.seen.len() == MAX_SEEN_WORK
&& let Some(expired) = self.seen.pop_front()
{
self.seen_ids.remove(&expired);
}
self.seen_ids.insert(id.clone());
self.seen.push_back(id);
}
fn reject_queued(&mut self) {
while let Some(work) = self.queued.pop_front() {
self.queued_ids.remove(work.id.as_str());
self.emit_work(work.id, WorkDisposition::RejectedShuttingDown);
}
}
fn transition(
&self,
state: SessionState,
reason: SessionReason,
retry_in: Option<Duration>,
generation: u64,
) {
self.state.store(state as u8, Ordering::Release);
let status = SessionStatus {
state,
generation,
transport_connected: matches!(state, SessionState::Online | SessionState::Degraded),
agent_ready: state == SessionState::Online,
consecutive_failures: self.failures,
};
*lock(&self.status) = status;
let _ = self.observations.try_send(SessionObservation::Transition {
status,
reason,
retry_in,
});
}
fn emit_work(&self, id: BoundedText<MAX_IDENTIFIER_BYTES>, disposition: WorkDisposition) {
let _ = self
.observations
.try_send(SessionObservation::Work { id, disposition });
}
fn status_snapshot(&self) -> SessionStatus {
*lock(&self.status)
}
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}