use crate::clock::{Clock, SystemClock};
use crate::types::PendingGeneration;
use std::collections::HashMap;
use std::sync::Arc;
use thiserror::Error;
use tokio::sync::RwLock;
use uuid::Uuid;
pub const MAX_PENDING_SESSIONS: usize = 1000;
const MAX_SINGLE_SESSION_BYTES: usize = mcp_execution_introspector::MAX_TOOL_COUNT
* (mcp_execution_introspector::MAX_TOOL_NAME_LEN
+ mcp_execution_introspector::MAX_TOOL_DESCRIPTION_LEN
+ mcp_execution_introspector::MAX_SCHEMA_SIZE_BYTES
+ mcp_execution_introspector::MAX_SCHEMA_SIZE_BYTES);
pub const MAX_TOTAL_PENDING_BYTES: usize = MAX_SINGLE_SESSION_BYTES * 4;
#[derive(Debug, Error)]
pub enum StateError {
#[error("too many pending generation sessions: at capacity limit of {limit}")]
AtCapacity {
limit: usize,
},
#[error(
"pending generation sessions would exceed the aggregate memory budget of {limit} bytes"
)]
MemoryBudgetExceeded {
limit: usize,
},
}
fn estimate_size_bytes(generation: &PendingGeneration) -> usize {
serde_json::to_vec(&generation.server_info).map_or(usize::MAX, |bytes| bytes.len())
}
#[derive(Debug, Default)]
struct PendingTable {
entries: HashMap<Uuid, PendingEntry>,
total_bytes: usize,
}
#[derive(Debug)]
struct PendingEntry {
generation: PendingGeneration,
size_bytes: usize,
}
impl PendingTable {
fn sweep_expired(&mut self, clock: &dyn Clock) {
let total_bytes = &mut self.total_bytes;
self.entries.retain(|_, entry| {
let keep = !entry.generation.is_expired(clock);
if !keep {
*total_bytes -= entry.size_bytes;
}
keep
});
}
}
#[derive(Debug)]
pub struct StateManager {
pending: Arc<RwLock<PendingTable>>,
clock: Arc<dyn Clock>,
}
impl Default for StateManager {
fn default() -> Self {
Self::new()
}
}
impl StateManager {
#[must_use]
pub fn new() -> Self {
Self::with_clock(Arc::new(SystemClock))
}
#[must_use]
pub(crate) fn with_clock(clock: Arc<dyn Clock>) -> Self {
Self {
pending: Arc::new(RwLock::new(PendingTable::default())),
clock,
}
}
pub async fn store(&self, generation: PendingGeneration) -> Result<Uuid, StateError> {
let session_id = Uuid::new_v4();
let size_bytes = estimate_size_bytes(&generation);
{
let mut table = self.pending.write().await;
table.sweep_expired(self.clock.as_ref());
if table.entries.len() >= MAX_PENDING_SESSIONS {
return Err(StateError::AtCapacity {
limit: MAX_PENDING_SESSIONS,
});
}
if table.total_bytes.saturating_add(size_bytes) > MAX_TOTAL_PENDING_BYTES {
return Err(StateError::MemoryBudgetExceeded {
limit: MAX_TOTAL_PENDING_BYTES,
});
}
table.entries.insert(
session_id,
PendingEntry {
generation,
size_bytes,
},
);
table.total_bytes += size_bytes;
}
Ok(session_id)
}
pub async fn take(&self, session_id: Uuid) -> Option<PendingGeneration> {
let mut table = self.pending.write().await;
table.sweep_expired(self.clock.as_ref());
let entry = table.entries.remove(&session_id)?;
table.total_bytes -= entry.size_bytes;
drop(table);
let generation = entry.generation;
if generation.is_expired(self.clock.as_ref()) {
return None;
}
Some(generation)
}
pub async fn get(&self, session_id: Uuid) -> Option<PendingGeneration> {
let table = self.pending.read().await;
let clock = self.clock.as_ref();
table
.entries
.get(&session_id)
.filter(|entry| !entry.generation.is_expired(clock))
.map(|entry| entry.generation.clone())
}
pub async fn pending_count(&self) -> usize {
let table = self.pending.read().await;
let clock = self.clock.as_ref();
table
.entries
.values()
.filter(|entry| !entry.generation.is_expired(clock))
.count()
}
pub async fn cleanup_expired(&self) -> usize {
let mut table = self.pending.write().await;
let before = table.entries.len();
table.sweep_expired(self.clock.as_ref());
before - table.entries.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::{SystemClock, TestClock};
use crate::types::PendingGeneration;
use mcp_execution_core::{ServerConfig, ServerId, ToolName};
use mcp_execution_introspector::ServerInfo;
fn create_test_pending() -> PendingGeneration {
create_test_pending_with_clock(&SystemClock)
}
fn create_test_pending_with_clock(clock: &dyn Clock) -> PendingGeneration {
use mcp_execution_introspector::{ServerCapabilities, ToolInfo};
let server_id = ServerId::new("test").unwrap();
let server_info = ServerInfo {
id: server_id.clone(),
name: "Test Server".to_string(),
version: "1.0.0".to_string(),
capabilities: ServerCapabilities {
supports_tools: true,
supports_resources: false,
supports_prompts: false,
},
tools: vec![ToolInfo {
name: ToolName::new("test_tool").unwrap(),
description: "Test tool".to_string(),
input_schema: serde_json::json!({}),
output_schema: None,
}],
};
let config = ServerConfig::builder()
.command("echo".to_string())
.build()
.unwrap();
PendingGeneration::new(server_id, server_info, config, None, clock)
}
fn create_expired_pending() -> PendingGeneration {
let past_clock = TestClock::new(chrono::Utc::now() - chrono::Duration::hours(1));
create_test_pending_with_clock(&past_clock)
}
#[tokio::test]
async fn test_store_and_retrieve() {
let state = StateManager::new();
let pending = create_test_pending();
let session_id = state.store(pending.clone()).await.unwrap();
let retrieved = state.take(session_id).await;
assert!(retrieved.is_some());
let retrieved = retrieved.unwrap();
assert_eq!(retrieved.server_id, pending.server_id);
}
#[tokio::test]
async fn test_take_removes_session() {
let state = StateManager::new();
let pending = create_test_pending();
let session_id = state.store(pending).await.unwrap();
let first = state.take(session_id).await;
assert!(first.is_some());
let second = state.take(session_id).await;
assert!(second.is_none());
}
#[tokio::test]
async fn test_get_does_not_remove() {
let state = StateManager::new();
let pending = create_test_pending();
let session_id = state.store(pending).await.unwrap();
let first = state.get(session_id).await;
assert!(first.is_some());
let second = state.get(session_id).await;
assert!(second.is_some());
let taken = state.take(session_id).await;
assert!(taken.is_some());
}
#[tokio::test]
async fn test_expired_session() {
let state = StateManager::new();
let pending = create_expired_pending();
let session_id = state.store(pending).await.unwrap();
let retrieved = state.take(session_id).await;
assert!(retrieved.is_none());
}
#[tokio::test]
async fn test_pending_count() {
let state = StateManager::new();
assert_eq!(state.pending_count().await, 0);
let session_id = state.store(create_test_pending()).await.unwrap();
assert_eq!(state.pending_count().await, 1);
state.take(session_id).await;
assert_eq!(state.pending_count().await, 0);
}
#[tokio::test]
async fn test_cleanup_expired() {
let state = StateManager::new();
state.store(create_test_pending()).await.unwrap();
state.store(create_expired_pending()).await.unwrap();
assert_eq!(state.pending_count().await, 1);
let removed = state.cleanup_expired().await;
assert_eq!(removed, 1); }
#[tokio::test]
async fn test_shared_clock_drives_expiry() {
let start = chrono::Utc::now();
let clock = Arc::new(TestClock::new(start));
let state = StateManager::with_clock(Arc::clone(&clock) as Arc<dyn Clock>);
let pending = create_test_pending_with_clock(clock.as_ref());
let session_id = state.store(pending).await.unwrap();
assert!(state.get(session_id).await.is_some());
assert_eq!(state.pending_count().await, 1);
clock.set(
start
+ chrono::Duration::minutes(PendingGeneration::DEFAULT_TIMEOUT_MINUTES)
+ chrono::Duration::seconds(1),
);
assert!(
state.get(session_id).await.is_none(),
"expiry should track the injected clock, not Utc::now()"
);
assert_eq!(state.pending_count().await, 0);
assert_eq!(state.cleanup_expired().await, 1);
assert!(state.take(session_id).await.is_none());
}
#[tokio::test]
async fn test_concurrent_access() {
let state = Arc::new(StateManager::new());
let mut handles = vec![];
for i in 0..10 {
let state_clone = Arc::clone(&state);
handles.push(tokio::spawn(async move {
let mut pending = create_test_pending();
pending.server_id = ServerId::new(format!("server-{i}")).unwrap();
state_clone.store(pending).await
}));
}
for handle in handles {
handle.await.unwrap().unwrap();
}
assert_eq!(state.pending_count().await, 10);
}
#[tokio::test]
async fn test_lazy_cleanup_on_store() {
let state = StateManager::new();
{
let generation = create_expired_pending();
let size_bytes = estimate_size_bytes(&generation);
let mut table = state.pending.write().await;
table.entries.insert(
Uuid::new_v4(),
PendingEntry {
generation,
size_bytes,
},
);
table.total_bytes += size_bytes;
}
state.store(create_test_pending()).await.unwrap();
assert_eq!(state.pending_count().await, 1);
}
#[tokio::test]
async fn test_store_rejects_when_at_capacity() {
let state = StateManager::new();
for _ in 0..MAX_PENDING_SESSIONS {
state.store(create_test_pending()).await.unwrap();
}
let result = state.store(create_test_pending()).await;
assert!(result.is_err());
assert!(
matches!(result, Err(StateError::AtCapacity { limit }) if limit == MAX_PENDING_SESSIONS)
);
}
#[tokio::test]
async fn test_store_accepts_up_to_exact_capacity() {
let state = StateManager::new();
for _ in 0..MAX_PENDING_SESSIONS - 1 {
state.store(create_test_pending()).await.unwrap();
}
let result = state.store(create_test_pending()).await;
assert!(result.is_ok());
assert_eq!(state.pending_count().await, MAX_PENDING_SESSIONS);
}
#[tokio::test]
async fn test_store_rejects_when_would_exceed_memory_budget() {
let state = StateManager::new();
{
let mut table = state.pending.write().await;
table.total_bytes = MAX_TOTAL_PENDING_BYTES;
}
let result = state.store(create_test_pending()).await;
assert!(result.is_err());
assert!(
matches!(result, Err(StateError::MemoryBudgetExceeded { limit }) if limit == MAX_TOTAL_PENDING_BYTES)
);
}
#[tokio::test]
async fn test_store_accepts_at_exact_memory_budget() {
let state = StateManager::new();
let generation = create_test_pending();
let size_bytes = estimate_size_bytes(&generation);
{
let mut table = state.pending.write().await;
table.total_bytes = MAX_TOTAL_PENDING_BYTES - size_bytes;
}
let result = state.store(generation).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_take_decrements_total_bytes() {
let state = StateManager::new();
let generation = create_test_pending();
let size_bytes = estimate_size_bytes(&generation);
assert!(size_bytes > 0);
let session_id = state.store(generation).await.unwrap();
assert_eq!(state.pending.read().await.total_bytes, size_bytes);
state.take(session_id).await;
assert_eq!(state.pending.read().await.total_bytes, 0);
}
#[tokio::test]
async fn test_sweep_expired_decrements_total_bytes() {
let state = StateManager::new();
state.store(create_expired_pending()).await.unwrap();
assert!(state.pending.read().await.total_bytes > 0);
state.cleanup_expired().await;
assert_eq!(state.pending.read().await.total_bytes, 0);
}
}