use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use evorule_reactor::{EventSender, Fact, FactId, FactSender, FactsLog, Reactor, ReactorHandle};
use evorule_tcb::JsonValue;
use crate::auditor::{AuditEntry, Auditor};
pub const DEFAULT_MAX_SESSIONS: usize = 1000;
pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(30 * 60);
pub const REAPER_INTERVAL: Duration = Duration::from_secs(5 * 60);
pub const DEFAULT_SHARD_COUNT: usize = 16;
pub type SessionId = u64;
#[derive(Clone)]
pub struct Session {
pub command_tx: FactSender,
pub facts_log: FactsLog,
pub event_tx: EventSender,
pub handle: Arc<ReactorHandle>,
pub auditor: Arc<std::sync::Mutex<Auditor>>,
pub parent_session_id: Option<SessionId>,
pub initial_content_hash: Option<String>,
created_at: Arc<Instant>,
last_activity_ms: Arc<AtomicU64>,
}
impl Session {
pub fn is_finished(&self) -> bool {
self.handle.is_finished()
}
pub fn abort(&self) {
self.handle.abort();
}
pub fn current_phase(&self) -> Option<evorule_reactor::ReactorPhase> {
self.handle.current_phase()
}
pub fn causal_depth(&self) -> Option<usize> {
self.handle.causal_depth()
}
pub fn structural_invariant_violations(&self) -> u64 {
self.handle.structural_invariant_violations()
}
pub fn pending_io_count(&self) -> Option<usize> {
self.handle.pending_io_count()
}
pub fn current_step(&self) -> Option<usize> {
self.handle.current_step()
}
pub fn snapshot(&self) -> Option<evorule_reactor::ReactorStateSnapshot> {
self.handle.snapshot()
}
pub fn set_auto_verify(&self, enabled: bool, threshold: usize, interval: usize) {
if let Ok(mut auditor) = self.auditor.lock() {
auditor.set_auto_verify(enabled, threshold, interval);
}
}
pub fn is_auto_verify_enabled(&self) -> bool {
if let Ok(auditor) = self.auditor.lock() {
auditor.is_auto_verify_enabled()
} else {
false
}
}
pub fn parent_session_id(&self) -> Option<SessionId> {
self.parent_session_id
}
pub fn initial_content_hash(&self) -> Option<&str> {
self.initial_content_hash.as_deref()
}
pub fn last_activity(&self) -> Instant {
let ms = self.last_activity_ms.load(Ordering::Relaxed);
*self.created_at + Duration::from_millis(ms)
}
pub fn touch(&self) {
let now = Instant::now();
let ms = (now - *self.created_at).as_millis() as u64;
self.last_activity_ms.store(ms, Ordering::Relaxed);
}
pub fn audit_new(&self) -> usize {
if let Ok(mut auditor) = self.auditor.lock() {
auditor.audit_new()
} else {
0
}
}
pub fn audit_report(&self) -> String {
if let Ok(auditor) = self.auditor.lock() {
auditor.report()
} else {
String::from("{}")
}
}
pub fn audit_verify(&self) -> bool {
if let Ok(auditor) = self.auditor.lock() {
auditor.verify()
} else {
false
}
}
pub fn audit_export(&self) -> String {
if let Ok(auditor) = self.auditor.lock() {
auditor.export()
} else {
String::from("{}")
}
}
pub fn audit_import(&self, json_str: &str) -> (bool, bool) {
if let Ok(mut auditor) = self.auditor.lock() {
match auditor.import_and_verify(json_str) {
Ok(verify_ok) => (true, verify_ok),
Err(e) => {
tracing::warn!(error = %e, "audit_import: 导入失败");
(false, false)
}
}
} else {
(false, false)
}
}
pub fn audit_export_compressed(&self) -> Vec<u8> {
if let Ok(auditor) = self.auditor.lock() {
match auditor.export_compressed() {
Ok(data) => data,
Err(e) => {
tracing::warn!(error = %e, "audit_export_compressed: 压缩失败");
Vec::new()
}
}
} else {
Vec::new()
}
}
pub fn audit_import_compressed(&self, compressed: &[u8]) -> (bool, bool) {
if let Ok(mut auditor) = self.auditor.lock() {
match auditor.import_compressed_and_verify(compressed) {
Ok(verify_ok) => (true, verify_ok),
Err(e) => {
tracing::warn!(error = %e, "audit_import_compressed: 导入失败");
(false, false)
}
}
} else {
(false, false)
}
}
pub fn causal_chain(&self, fact_id: FactId) -> Vec<AuditEntry> {
if let Ok(auditor) = self.auditor.lock() {
auditor.causal_chain(fact_id)
} else {
Vec::new()
}
}
}
pub struct SessionManager {
core_eval: Vec<JsonValue>,
max_rounds: usize,
shards: Vec<Arc<Mutex<BTreeMap<SessionId, Session>>>>,
next_session_id: AtomicU64,
max_sessions: usize,
session_ttl: Duration,
wal_dir: Option<PathBuf>,
wal_fsync: bool,
max_wal_size_bytes: u64,
auto_verify: bool,
auto_verify_threshold: usize,
auto_verify_interval: usize,
count: AtomicU64,
pending_recycle: Mutex<Vec<FactsLog>>,
}
fn lock_mutex<T>(
mutex: &Mutex<T>,
) -> Result<std::sync::MutexGuard<'_, T>, PoisonError<std::sync::MutexGuard<'_, T>>> {
mutex.lock()
}
impl SessionManager {
pub fn new(core_eval: Vec<JsonValue>, max_rounds: usize) -> Self {
Self::with_limits_and_wal(
core_eval,
max_rounds,
DEFAULT_MAX_SESSIONS,
DEFAULT_SESSION_TTL,
None,
DEFAULT_SHARD_COUNT,
)
}
pub fn with_limits(
core_eval: Vec<JsonValue>,
max_rounds: usize,
max_sessions: usize,
session_ttl: Duration,
) -> Self {
Self::with_limits_and_wal(
core_eval,
max_rounds,
max_sessions,
session_ttl,
None,
DEFAULT_SHARD_COUNT,
)
}
pub fn with_limits_and_wal(
core_eval: Vec<JsonValue>,
max_rounds: usize,
max_sessions: usize,
session_ttl: Duration,
wal_dir: Option<PathBuf>,
shard_count: usize,
) -> Self {
Self::with_limits_and_wal_and_fsync(
core_eval,
max_rounds,
max_sessions,
session_ttl,
wal_dir,
shard_count,
false,
)
}
pub fn with_limits_and_wal_and_fsync(
core_eval: Vec<JsonValue>,
max_rounds: usize,
max_sessions: usize,
session_ttl: Duration,
wal_dir: Option<PathBuf>,
shard_count: usize,
wal_fsync: bool,
) -> Self {
Self::with_limits_and_wal_full(
core_eval,
max_rounds,
max_sessions,
session_ttl,
wal_dir,
shard_count,
wal_fsync,
100 * 1024 * 1024,
)
}
#[allow(clippy::too_many_arguments)]
pub fn with_limits_and_wal_full(
core_eval: Vec<JsonValue>,
max_rounds: usize,
max_sessions: usize,
session_ttl: Duration,
wal_dir: Option<PathBuf>,
shard_count: usize,
wal_fsync: bool,
max_wal_size_bytes: u64,
) -> Self {
Self::with_limits_and_wal_and_auto_verify(
core_eval,
max_rounds,
max_sessions,
session_ttl,
wal_dir,
shard_count,
wal_fsync,
max_wal_size_bytes,
false,
1000,
1,
)
}
#[allow(clippy::too_many_arguments)]
pub fn with_limits_and_wal_and_auto_verify(
core_eval: Vec<JsonValue>,
max_rounds: usize,
max_sessions: usize,
session_ttl: Duration,
wal_dir: Option<PathBuf>,
shard_count: usize,
wal_fsync: bool,
max_wal_size_bytes: u64,
auto_verify: bool,
auto_verify_threshold: usize,
auto_verify_interval: usize,
) -> Self {
let shards = (0..shard_count)
.map(|_| Arc::new(Mutex::new(BTreeMap::new())))
.collect();
Self {
core_eval,
max_rounds,
shards,
next_session_id: AtomicU64::new(1),
max_sessions,
session_ttl,
wal_dir,
wal_fsync,
max_wal_size_bytes,
auto_verify,
auto_verify_threshold,
auto_verify_interval: if auto_verify_interval == 0 {
1
} else {
auto_verify_interval
},
count: AtomicU64::new(0),
pending_recycle: Mutex::new(Vec::new()),
}
}
fn get_shard_idx(&self, id: SessionId) -> usize {
(id as usize) % self.shards.len()
}
fn get_shard(&self, id: SessionId) -> &Arc<Mutex<BTreeMap<SessionId, Session>>> {
&self.shards[self.get_shard_idx(id)]
}
pub fn create_session(&self) -> Result<SessionId, SessionError> {
let current = self.count.load(Ordering::Relaxed);
if current >= self.max_sessions as u64 {
tracing::warn!(
current,
max = self.max_sessions,
"Session creation rejected: limit exceeded"
);
return Err(SessionError::LimitExceeded {
current: current as usize,
max: self.max_sessions,
});
}
let session_id = self.next_session_id.fetch_add(1, Ordering::Relaxed);
let facts_log = self.create_facts_log(session_id);
let reactor = Reactor::builder(self.core_eval.clone())
.max_rounds(self.max_rounds)
.facts_log(facts_log)
.build();
let (command_tx, _event_rx, event_tx, handle, facts_log) = reactor.spawn();
let shard = self.get_shard(session_id);
let mut shard_guard = match lock_mutex(shard) {
Ok(g) => g,
Err(e) => {
tracing::warn!("SessionManager shard mutex poisoned, recovering");
e.into_inner()
}
};
let new_count = self.count.fetch_add(1, Ordering::Relaxed) + 1;
tracing::info!(
session_id,
active = new_count,
max = self.max_sessions,
"Session created (long-running reactor spawned)"
);
let auditor = Arc::new(std::sync::Mutex::new(Auditor::new_with_auto_verify(
facts_log.clone(),
self.auto_verify,
self.auto_verify_threshold,
self.auto_verify_interval,
)));
shard_guard.insert(
session_id,
Session {
command_tx,
facts_log,
event_tx,
handle: Arc::new(handle),
auditor,
parent_session_id: None,
initial_content_hash: None,
created_at: Arc::new(Instant::now()),
last_activity_ms: Arc::new(AtomicU64::new(0)),
},
);
Ok(session_id)
}
pub fn create_session_from_parent(
&self,
parent_id: SessionId,
) -> Result<SessionId, SessionError> {
self.create_session_from_parent_at_version(parent_id, None)
}
pub fn create_session_from_parent_at_version(
&self,
parent_id: SessionId,
version: Option<u64>,
) -> Result<SessionId, SessionError> {
let parent = self
.get_session(parent_id)
.ok_or(SessionError::NotFound { id: parent_id })?;
let current = self.count.load(Ordering::Relaxed);
if current >= self.max_sessions as u64 {
return Err(SessionError::LimitExceeded {
current: current as usize,
max: self.max_sessions,
});
}
let session_id = self.next_session_id.fetch_add(1, Ordering::Relaxed);
let (initial_content_hash, initial_payload, initial_version) = match version {
Some(v) => {
let payload = rewind_payload(&parent.facts_log, v)
.ok_or(SessionError::InvalidVersion { version: v })?;
(
blake3::hash(payload.to_string().as_bytes())
.to_hex()
.to_string(),
payload,
v,
)
}
None => {
let (payload, _, version) = parent.facts_log.snapshot();
(
blake3::hash(payload.to_string().as_bytes())
.to_hex()
.to_string(),
payload,
version,
)
}
};
let facts_log = self.create_facts_log(session_id);
facts_log.set_initial_state(initial_payload, initial_version);
let reactor = Reactor::builder(self.core_eval.clone())
.max_rounds(self.max_rounds)
.facts_log(facts_log)
.build();
let (command_tx, _event_rx, event_tx, handle, facts_log) = reactor.spawn();
let shard = self.get_shard(session_id);
let mut shard_guard = match lock_mutex(shard) {
Ok(g) => g,
Err(e) => {
tracing::warn!("SessionManager shard mutex poisoned, recovering");
e.into_inner()
}
};
let new_count = self.count.fetch_add(1, Ordering::Relaxed) + 1;
tracing::info!(
session_id,
parent_id,
active = new_count,
max = self.max_sessions,
"Session created from parent (cross-session causality)"
);
let auditor = Arc::new(std::sync::Mutex::new(Auditor::new_with_auto_verify(
facts_log.clone(),
self.auto_verify,
self.auto_verify_threshold,
self.auto_verify_interval,
)));
shard_guard.insert(
session_id,
Session {
command_tx,
facts_log,
event_tx,
handle: Arc::new(handle),
auditor,
parent_session_id: Some(parent_id),
initial_content_hash: Some(initial_content_hash),
created_at: Arc::new(Instant::now()),
last_activity_ms: Arc::new(AtomicU64::new(0)),
},
);
Ok(session_id)
}
pub fn core_eval(&self) -> &[JsonValue] {
&self.core_eval
}
pub fn replace_core_eval(&mut self, new_core_eval: Vec<JsonValue>) -> Vec<JsonValue> {
let old = std::mem::replace(&mut self.core_eval, new_core_eval);
tracing::info!(
old_rules = old.len(),
new_rules = self.core_eval.len(),
"SessionManager core_eval replaced (only new sessions will use new rules)"
);
old
}
pub fn touch_session(&self, id: SessionId) {
let shard = self.get_shard(id);
let mut shard_guard = match lock_mutex(shard) {
Ok(g) => g,
Err(e) => {
tracing::warn!("SessionManager shard mutex poisoned, recovering");
e.into_inner()
}
};
if let Some(session) = shard_guard.get_mut(&id) {
session.touch();
}
}
pub fn get_session(&self, id: SessionId) -> Option<Session> {
let shard = self.get_shard(id);
let shard_guard = match lock_mutex(shard) {
Ok(g) => g,
Err(e) => {
tracing::warn!("SessionManager shard mutex poisoned, recovering");
e.into_inner()
}
};
shard_guard.get(&id).cloned()
}
pub fn close_session(&self, id: SessionId) -> Result<Arc<ReactorHandle>, SessionError> {
let shard = self.get_shard(id);
let mut shard_guard = match lock_mutex(shard) {
Ok(g) => g,
Err(e) => {
tracing::warn!("SessionManager shard mutex poisoned, recovering");
e.into_inner()
}
};
let session = shard_guard
.remove(&id)
.ok_or(SessionError::NotFound { id })?;
self.count.fetch_sub(1, Ordering::Relaxed);
if self.wal_dir.is_none() {
if let Ok(mut pending) = self.pending_recycle.lock() {
pending.push(session.facts_log.clone());
}
}
tracing::info!("Session {} closing (command_tx dropped)", id);
Ok(session.handle)
}
pub fn list_sessions(&self) -> Vec<SessionId> {
let mut ids = Vec::new();
for shard in &self.shards {
let shard_guard = match lock_mutex(shard) {
Ok(g) => g,
Err(e) => {
tracing::warn!("SessionManager shard mutex poisoned, recovering");
e.into_inner()
}
};
for id in shard_guard.keys() {
ids.push(*id);
}
}
ids.sort();
ids
}
pub fn reap_finished(&self) -> usize {
self.reclaim_pending();
let mut reaped = 0;
for shard in &self.shards {
let mut shard_guard = match lock_mutex(shard) {
Ok(g) => g,
Err(e) => {
tracing::warn!("SessionManager shard mutex poisoned, recovering");
e.into_inner()
}
};
let finished_ids: Vec<SessionId> = shard_guard
.iter()
.filter(|(_, session)| session.is_finished())
.map(|(id, _)| *id)
.collect();
for id in finished_ids {
if let Some(_session) = shard_guard.remove(&id) {
tracing::debug!("Session {} reaped (reactor finished)", id);
reaped += 1;
}
}
}
if reaped > 0 {
self.count.fetch_sub(reaped as u64, Ordering::Relaxed);
}
reaped
}
pub fn reap_expired(&self) -> usize {
let now = Instant::now();
let mut reaped = 0;
for shard in &self.shards {
let mut shard_guard = match lock_mutex(shard) {
Ok(g) => g,
Err(e) => {
tracing::warn!("SessionManager shard mutex poisoned, recovering");
e.into_inner()
}
};
let before = shard_guard.len();
shard_guard.retain(|id, session| {
let elapsed = now.duration_since(session.last_activity());
if elapsed > self.session_ttl {
tracing::info!(
session_id = id,
elapsed_secs = elapsed.as_secs(),
ttl_secs = self.session_ttl.as_secs(),
"Session expired (TTL reached)"
);
false
} else {
true
}
});
reaped += before - shard_guard.len();
}
if reaped > 0 {
self.count.fetch_sub(reaped as u64, Ordering::Relaxed);
}
reaped
}
pub fn reap_all(&self) -> usize {
let finished = self.reap_finished();
let expired = self.reap_expired();
finished + expired
}
pub fn len(&self) -> usize {
self.count.load(Ordering::Relaxed) as usize
}
pub fn is_empty(&self) -> bool {
self.count.load(Ordering::Relaxed) == 0
}
fn create_facts_log(&self, session_id: SessionId) -> FactsLog {
if let Some(ref wal_dir) = self.wal_dir {
let wal_path = wal_dir.join(format!("session_{}.wal", session_id));
match FactsLog::with_wal_options(&wal_path, self.max_wal_size_bytes, self.wal_fsync) {
Ok(facts_log) => {
tracing::debug!(session_id, wal_path = %wal_path.display(), fsync = self.wal_fsync, max_wal_size_bytes = self.max_wal_size_bytes, "FactsLog created with WAL");
facts_log
}
Err(e) => {
tracing::warn!(session_id, error = %e, "Failed to create WAL FactsLog, falling back to memory mode");
FactsLog::new()
}
}
} else {
FactsLog::new()
}
}
fn reclaim_pending(&self) {
if let Ok(mut pending) = self.pending_recycle.lock() {
if pending.is_empty() {
return;
}
let still_pending: Vec<FactsLog> =
pending.drain(..).filter(|fl| !fl.is_reusable()).collect();
*pending = still_pending;
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SessionError {
#[error("Session {id} not found")]
NotFound {
id: SessionId,
},
#[error("Session limit exceeded: {current}/{max}")]
LimitExceeded {
current: usize,
max: usize,
},
#[error("Invalid version {version}")]
InvalidVersion {
version: u64,
},
}
fn rewind_payload(facts_log: &FactsLog, target_version: u64) -> Option<JsonValue> {
if target_version == 0 {
return Some(JsonValue::empty_object());
}
let history = facts_log.history_with_versions();
let mut payload = JsonValue::empty_object();
let mut version: u64 = 0;
for (version_before, fact) in history {
match &fact {
Fact::StateTransition { new_payload, .. } => {
payload = new_payload.clone();
version = version_before + 1;
}
Fact::IoResponse { .. } => {
version = version_before + 1;
}
Fact::PayloadUpdate { path, value, .. } => {
crate::time_machine::apply_payload_update(&mut payload, path, value.clone());
version = version_before + 1;
}
_ => {}
}
if version == target_version {
break;
}
}
if version < target_version {
return None;
}
Some(payload)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
#![allow(clippy::panic, clippy::expect_used)]
use super::*;
use evorule_reactor::Fact;
use std::collections::BTreeMap;
fn make_core_eval() -> Vec<JsonValue> {
let mut params = BTreeMap::new();
params.insert("attr".to_string(), JsonValue::string("x"));
params.insert("delta".to_string(), JsonValue::Integer(1));
let mut instr = BTreeMap::new();
instr.insert("type".to_string(), JsonValue::string("increment"));
instr.insert("params".to_string(), JsonValue::Object(params));
vec![JsonValue::Object(instr)]
}
#[tokio::test]
async fn test_create_and_get_session() {
let core_eval = make_core_eval();
let mgr = SessionManager::new(core_eval, 100);
let id1 = mgr.create_session().unwrap();
let id2 = mgr.create_session().unwrap();
assert_ne!(id1, id2);
assert_eq!(mgr.len(), 2);
assert!(mgr.get_session(id1).is_some());
assert!(mgr.get_session(id2).is_some());
assert!(mgr.get_session(999).is_none());
}
#[tokio::test]
async fn test_close_session() {
let core_eval = make_core_eval();
let mgr = SessionManager::new(core_eval, 100);
let id = mgr.create_session().unwrap();
assert_eq!(mgr.len(), 1);
let handle = mgr.close_session(id).unwrap();
assert_eq!(mgr.len(), 0);
assert!(mgr.get_session(id).is_none());
assert!(matches!(
mgr.close_session(id),
Err(SessionError::NotFound { .. })
));
drop(handle);
}
#[tokio::test]
async fn test_list_sessions() {
let core_eval = make_core_eval();
let mgr = SessionManager::new(core_eval, 100);
assert!(mgr.list_sessions().is_empty());
let id1 = mgr.create_session().unwrap();
let id2 = mgr.create_session().unwrap();
let mut list = mgr.list_sessions();
list.sort();
assert_eq!(list, vec![id1, id2]);
}
#[tokio::test]
async fn test_session_command_works() {
let core_eval = make_core_eval();
let mgr = SessionManager::new(core_eval, 100);
let id = mgr.create_session().unwrap();
let session = mgr.get_session(id).unwrap();
let mut params = BTreeMap::new();
params.insert("attr".to_string(), JsonValue::string("x"));
params.insert("delta".to_string(), JsonValue::Integer(5));
let mut instr = BTreeMap::new();
instr.insert("type".to_string(), JsonValue::string("increment"));
instr.insert("params".to_string(), JsonValue::Object(params));
session
.command_tx
.send(Fact::Command {
id: evorule_reactor::FactId(1),
instruction: JsonValue::Object(instr),
})
.unwrap();
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
let history = session.facts_log.history();
assert!(
history.len() >= 2,
"Expected at least 2 facts, got {}",
history.len()
);
}
#[tokio::test]
async fn test_close_session_triggers_reactor_exit() {
let core_eval = make_core_eval();
let mgr = SessionManager::new(core_eval, 100);
let id = mgr.create_session().unwrap();
let handle = mgr.close_session(id).unwrap();
let handle_inner = Arc::try_unwrap(handle).unwrap_or_else(|_| {
panic!("Expected single reference to handle");
});
let result = handle_inner.join().await;
assert!(result.is_ok(), "Expected graceful Ok(())");
}
#[tokio::test]
async fn test_is_empty_and_len() {
let core_eval = make_core_eval();
let mgr = SessionManager::new(core_eval, 100);
assert!(mgr.is_empty());
assert_eq!(mgr.len(), 0);
mgr.create_session().unwrap();
assert!(!mgr.is_empty());
assert_eq!(mgr.len(), 1);
}
#[tokio::test]
async fn test_session_limit_exceeded() {
let core_eval = make_core_eval();
let mgr = SessionManager::with_limits(core_eval, 100, 2, Duration::from_secs(3600));
let id1 = mgr.create_session().unwrap();
let id2 = mgr.create_session().unwrap();
assert_eq!(mgr.len(), 2);
let result = mgr.create_session();
assert!(matches!(
result,
Err(SessionError::LimitExceeded { current: 2, max: 2 })
));
assert_eq!(mgr.len(), 2);
let _handle = mgr.close_session(id1).unwrap();
let id3 = mgr.create_session().unwrap();
assert_eq!(mgr.len(), 2);
assert!(mgr.get_session(id3).is_some());
let _ = mgr.close_session(id2);
let _ = mgr.close_session(id3);
}
#[tokio::test]
async fn test_reap_expired() {
let core_eval = make_core_eval();
let mgr = SessionManager::with_limits(core_eval, 100, 100, Duration::from_millis(100));
let id1 = mgr.create_session().unwrap();
let id2 = mgr.create_session().unwrap();
assert_eq!(mgr.len(), 2);
tokio::time::sleep(Duration::from_millis(150)).await;
let reaped = mgr.reap_expired();
assert_eq!(reaped, 2);
assert_eq!(mgr.len(), 0);
assert!(mgr.get_session(id1).is_none());
assert!(mgr.get_session(id2).is_none());
}
#[tokio::test]
async fn test_touch_session_prevents_expiry() {
let core_eval = make_core_eval();
let mgr = SessionManager::with_limits(core_eval, 100, 100, Duration::from_millis(100));
let id = mgr.create_session().unwrap();
tokio::time::sleep(Duration::from_millis(60)).await;
mgr.touch_session(id);
tokio::time::sleep(Duration::from_millis(60)).await;
let reaped = mgr.reap_expired();
assert_eq!(reaped, 0);
assert!(mgr.get_session(id).is_some());
tokio::time::sleep(Duration::from_millis(120)).await;
let reaped = mgr.reap_expired();
assert_eq!(reaped, 1);
}
#[tokio::test]
async fn test_sharding_distribution() {
let core_eval = make_core_eval();
let mgr = SessionManager::with_limits_and_wal(
core_eval,
100,
100,
Duration::from_secs(3600),
None,
4,
);
for _ in 0..100 {
mgr.create_session().unwrap();
}
assert_eq!(mgr.len(), 100);
let mut counts = [0; 4];
for shard in &mgr.shards {
let shard_guard = match lock_mutex(shard) {
Ok(g) => g,
Err(e) => e.into_inner(),
};
for id in shard_guard.keys() {
let idx = *id as usize % 4;
counts[idx] += 1;
}
}
assert!(
counts.iter().all(|&c| c > 0),
"All shards should have sessions"
);
assert!(
counts.iter().all(|&c| c < 50),
"No shard should have too many sessions"
);
}
#[tokio::test]
async fn test_cross_session_causality_e2e() {
let core_eval = make_core_eval();
let mgr = SessionManager::new(core_eval, 100);
let parent_id = mgr.create_session().unwrap();
let parent = mgr.get_session(parent_id).unwrap();
assert!(parent.parent_session_id().is_none());
assert!(parent.initial_content_hash().is_none());
let mut params = BTreeMap::new();
params.insert("attr".to_string(), JsonValue::string("x"));
params.insert("delta".to_string(), JsonValue::Integer(42));
let mut instr = BTreeMap::new();
instr.insert("type".to_string(), JsonValue::string("increment"));
instr.insert("params".to_string(), JsonValue::Object(params));
parent
.command_tx
.send(Fact::Command {
id: evorule_reactor::FactId(1),
instruction: JsonValue::Object(instr),
})
.unwrap();
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let (parent_payload, _, _) = parent.facts_log.snapshot();
let expected_hash = blake3::hash(parent_payload.to_string().as_bytes())
.to_hex()
.to_string();
let child_id = mgr.create_session_from_parent(parent_id).unwrap();
let child = mgr.get_session(child_id).unwrap();
assert_eq!(child.parent_session_id(), Some(parent_id));
assert_eq!(child.initial_content_hash(), Some(expected_hash.as_str()));
assert_ne!(child_id, parent_id);
assert_eq!(mgr.len(), 2);
let mut params2 = BTreeMap::new();
params2.insert("attr".to_string(), JsonValue::string("x"));
params2.insert("delta".to_string(), JsonValue::Integer(10));
let mut instr2 = BTreeMap::new();
instr2.insert("type".to_string(), JsonValue::string("increment"));
instr2.insert("params".to_string(), JsonValue::Object(params2));
child
.command_tx
.send(Fact::Command {
id: evorule_reactor::FactId(1),
instruction: JsonValue::Object(instr2),
})
.unwrap();
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let child_history = child.facts_log.history();
assert!(child_history.len() >= 2);
let grandchild_id = mgr.create_session_from_parent(child_id).unwrap();
let grandchild = mgr.get_session(grandchild_id).unwrap();
assert_eq!(grandchild.parent_session_id(), Some(child_id));
assert!(grandchild.initial_content_hash().is_some());
let (child_payload, _, _) = child.facts_log.snapshot();
let child_hash = blake3::hash(child_payload.to_string().as_bytes())
.to_hex()
.to_string();
assert_eq!(grandchild.initial_content_hash(), Some(child_hash.as_str()));
assert_eq!(mgr.len(), 3);
let result = mgr.create_session_from_parent(999_999);
assert!(matches!(
result,
Err(SessionError::NotFound { id: 999_999 })
));
let _ = mgr.close_session(grandchild_id);
let _ = mgr.close_session(child_id);
let _ = mgr.close_session(parent_id);
}
#[tokio::test]
async fn test_cross_session_causality_initial_state() {
let core_eval = make_core_eval();
let mgr = SessionManager::new(core_eval, 100);
let parent_id = mgr.create_session().unwrap();
let parent = mgr.get_session(parent_id).unwrap();
let (parent_payload, _, _) = parent.facts_log.snapshot();
let expected_hash = blake3::hash(parent_payload.to_string().as_bytes())
.to_hex()
.to_string();
let child_id = mgr.create_session_from_parent(parent_id).unwrap();
let child = mgr.get_session(child_id).unwrap();
assert_eq!(child.parent_session_id(), Some(parent_id));
assert_eq!(child.initial_content_hash(), Some(expected_hash.as_str()));
let _ = mgr.close_session(child_id);
let _ = mgr.close_session(parent_id);
}
}