mcp_execution_server/state.rs
1//! State management for pending generation sessions.
2//!
3//! The `StateManager` stores temporary session data between `introspect_server`
4//! and `save_categorized_tools` calls. Sessions expire after 30 minutes and
5//! are cleaned up lazily on each operation.
6
7use crate::clock::{Clock, SystemClock};
8use crate::types::PendingGeneration;
9use std::collections::HashMap;
10use std::sync::Arc;
11use thiserror::Error;
12use tokio::sync::RwLock;
13use uuid::Uuid;
14
15/// Maximum number of concurrent pending generation sessions (denial-of-service protection,
16/// CWE-400).
17///
18/// Sessions are only ever swept lazily (as a side effect of [`StateManager::store`]/
19/// [`StateManager::take`]), so without a hard ceiling a caller who repeatedly calls
20/// `introspect_server` without ever following up with `save_categorized_tools` could grow
21/// this table without bound. This is a structural backstop against unbounded `HashMap` growth
22/// (and the iteration cost of sweeping it) independent of session size; [`MAX_TOTAL_PENDING_BYTES`]
23/// below is what actually bounds memory, since a session's size can vary by orders of
24/// magnitude depending on the introspected server's tool count.
25pub const MAX_PENDING_SESSIONS: usize = 1000;
26
27/// Approximate upper bound on a single session's in-memory footprint: up to
28/// `mcp_execution_introspector::MAX_TOOL_COUNT` tools, each up to `MAX_TOOL_NAME_LEN` +
29/// `MAX_TOOL_DESCRIPTION_LEN` + two independently-bounded schemas (`input_schema` and
30/// `output_schema`, each up to `MAX_SCHEMA_SIZE_BYTES` — see `mcp_execution_introspector`'s
31/// `build_tool_info`). Used only to derive [`MAX_TOTAL_PENDING_BYTES`] below.
32const MAX_SINGLE_SESSION_BYTES: usize = mcp_execution_introspector::MAX_TOOL_COUNT
33 * (mcp_execution_introspector::MAX_TOOL_NAME_LEN
34 + mcp_execution_introspector::MAX_TOOL_DESCRIPTION_LEN
35 + mcp_execution_introspector::MAX_SCHEMA_SIZE_BYTES
36 + mcp_execution_introspector::MAX_SCHEMA_SIZE_BYTES);
37
38/// Maximum combined approximate memory footprint of every pending session at once
39/// (denial-of-service protection, CWE-400).
40///
41/// [`MAX_PENDING_SESSIONS`] alone does not bound memory: per-item caps on tool count/size
42/// multiply into a per-session footprint that can itself be hundreds of megabytes (this
43/// module's `MAX_SINGLE_SESSION_BYTES`), so a count-only cap of 1000 sessions could still reach
44/// hundreds of gigabytes in the worst case (issue #198 S1). This budget is checked in
45/// addition to the count cap and is the one that actually matters for memory: set to four
46/// times `MAX_SINGLE_SESSION_BYTES`, generous enough for a few concurrent large introspections
47/// without silently capping realistic multi-server usage, while categorically ruling out the
48/// unbounded multiplication a pure count cap allows.
49pub const MAX_TOTAL_PENDING_BYTES: usize = MAX_SINGLE_SESSION_BYTES * 4;
50
51/// Errors from [`StateManager::store`].
52#[derive(Debug, Error)]
53pub enum StateError {
54 /// The pending-session table is already at its configured capacity.
55 #[error("too many pending generation sessions: at capacity limit of {limit}")]
56 AtCapacity {
57 /// The configured maximum (`MAX_PENDING_SESSIONS`).
58 limit: usize,
59 },
60
61 /// Storing this session would push the aggregate approximate memory footprint of all
62 /// pending sessions past [`MAX_TOTAL_PENDING_BYTES`].
63 #[error(
64 "pending generation sessions would exceed the aggregate memory budget of {limit} bytes"
65 )]
66 MemoryBudgetExceeded {
67 /// The configured maximum (`MAX_TOTAL_PENDING_BYTES`).
68 limit: usize,
69 },
70}
71
72/// Estimates a [`PendingGeneration`]'s in-memory footprint from its serialized
73/// [`ServerInfo`](mcp_execution_introspector::ServerInfo) size.
74///
75/// This is an approximation (it ignores `config`/`output_dir_override`, both small relative to
76/// `server_info`'s tool list), used only to enforce [`MAX_TOTAL_PENDING_BYTES`]. A serialization
77/// failure is treated as exceeding any bound, rather than silently under-counting a session
78/// that could not be measured.
79///
80/// `serde_json::to_vec`'s `Serialize` implementation for `Value` (nested inside `server_info`'s
81/// tool schemas) is unconditionally recursive with no depth limit of its own — the same class
82/// of unguarded recursion `mcp-execution-codegen`'s `MAX_SCHEMA_RECURSION_DEPTH` defends
83/// against (issue #303). It isn't guarded separately here for the same reason it isn't guarded
84/// in `mcp-execution-introspector`'s `build_tool_info`, which is what originally built every
85/// schema in `server_info`: `serde_json`'s deserializer already enforces its own default
86/// recursion limit while parsing a `tools/list` response, so nothing reaching this function was
87/// ever deep enough to threaten a recursive serialize.
88fn estimate_size_bytes(generation: &PendingGeneration) -> usize {
89 serde_json::to_vec(&generation.server_info).map_or(usize::MAX, |bytes| bytes.len())
90}
91
92/// The pending-session table itself, tracking a running approximate byte total alongside the
93/// entries so [`MAX_TOTAL_PENDING_BYTES`] can be checked in O(1) rather than re-measuring every
94/// session on each call.
95#[derive(Debug, Default)]
96struct PendingTable {
97 entries: HashMap<Uuid, PendingEntry>,
98 total_bytes: usize,
99}
100
101/// A single stored session alongside its precomputed size, so removing it can decrement
102/// [`PendingTable::total_bytes`] without re-measuring.
103#[derive(Debug)]
104struct PendingEntry {
105 generation: PendingGeneration,
106 size_bytes: usize,
107}
108
109impl PendingTable {
110 /// Removes every expired entry, keeping `total_bytes` consistent with what remains.
111 fn sweep_expired(&mut self, clock: &dyn Clock) {
112 let total_bytes = &mut self.total_bytes;
113 self.entries.retain(|_, entry| {
114 let keep = !entry.generation.is_expired(clock);
115 if !keep {
116 *total_bytes -= entry.size_bytes;
117 }
118 keep
119 });
120 }
121}
122
123/// State manager for pending generation sessions.
124///
125/// Uses an in-memory `HashMap` protected by `RwLock` for thread-safe access.
126/// Sessions expire after 30 minutes and are cleaned up lazily.
127///
128/// # Examples
129///
130/// ```
131/// use mcp_execution_server::state::StateManager;
132/// use mcp_execution_server::types::PendingGeneration;
133/// use mcp_execution_server::clock::SystemClock;
134/// use mcp_execution_core::{ServerId, ServerConfig};
135/// use mcp_execution_introspector::ServerInfo;
136///
137/// # async fn example() {
138/// let state = StateManager::new();
139///
140/// # let server_info = ServerInfo {
141/// # id: ServerId::new("test").unwrap(),
142/// # name: "Test".to_string(),
143/// # version: "1.0.0".to_string(),
144/// # capabilities: mcp_execution_introspector::ServerCapabilities {
145/// # supports_tools: true,
146/// # supports_resources: false,
147/// # supports_prompts: false,
148/// # },
149/// # tools: vec![],
150/// # };
151/// let pending = PendingGeneration::new(
152/// ServerId::new("github").unwrap(),
153/// server_info,
154/// ServerConfig::builder().command("npx".to_string()).build().unwrap(),
155/// None,
156/// &SystemClock,
157/// );
158///
159/// // Store and get session ID
160/// let session_id = state.store(pending).await.unwrap();
161///
162/// // Retrieve session data
163/// let retrieved = state.take(session_id).await;
164/// assert!(retrieved.is_some());
165/// # }
166/// ```
167#[derive(Debug)]
168pub struct StateManager {
169 pending: Arc<RwLock<PendingTable>>,
170 clock: Arc<dyn Clock>,
171}
172
173impl Default for StateManager {
174 fn default() -> Self {
175 Self::new()
176 }
177}
178
179impl StateManager {
180 /// Creates a new state manager using the real system clock.
181 #[must_use]
182 pub fn new() -> Self {
183 Self::with_clock(Arc::new(SystemClock))
184 }
185
186 /// Creates a new state manager backed by a custom clock.
187 ///
188 /// Used in tests to inject a fake clock so session expiry can be
189 /// exercised deterministically.
190 #[must_use]
191 pub(crate) fn with_clock(clock: Arc<dyn Clock>) -> Self {
192 Self {
193 pending: Arc::new(RwLock::new(PendingTable::default())),
194 clock,
195 }
196 }
197
198 /// Stores a pending generation and returns a session ID.
199 ///
200 /// This operation also performs lazy cleanup of expired sessions.
201 ///
202 /// # Errors
203 ///
204 /// Returns [`StateError::AtCapacity`] if the pending-session table is already at
205 /// [`MAX_PENDING_SESSIONS`] after expired sessions have been swept, or
206 /// [`StateError::MemoryBudgetExceeded`] if storing this session would push the aggregate
207 /// approximate memory footprint of all pending sessions past [`MAX_TOTAL_PENDING_BYTES`].
208 ///
209 /// # Examples
210 ///
211 /// ```
212 /// use mcp_execution_server::state::StateManager;
213 /// # use mcp_execution_server::types::PendingGeneration;
214 /// # use mcp_execution_core::{ServerId, ServerConfig};
215 /// # use mcp_execution_introspector::ServerInfo;
216 /// # use std::path::PathBuf;
217 ///
218 /// # async fn example(pending: PendingGeneration) {
219 /// let state = StateManager::new();
220 /// let session_id = state.store(pending).await.unwrap();
221 /// # }
222 /// ```
223 pub async fn store(&self, generation: PendingGeneration) -> Result<Uuid, StateError> {
224 let session_id = Uuid::new_v4();
225 let size_bytes = estimate_size_bytes(&generation);
226
227 {
228 let mut table = self.pending.write().await;
229 table.sweep_expired(self.clock.as_ref());
230
231 if table.entries.len() >= MAX_PENDING_SESSIONS {
232 return Err(StateError::AtCapacity {
233 limit: MAX_PENDING_SESSIONS,
234 });
235 }
236
237 if table.total_bytes.saturating_add(size_bytes) > MAX_TOTAL_PENDING_BYTES {
238 return Err(StateError::MemoryBudgetExceeded {
239 limit: MAX_TOTAL_PENDING_BYTES,
240 });
241 }
242
243 table.entries.insert(
244 session_id,
245 PendingEntry {
246 generation,
247 size_bytes,
248 },
249 );
250 table.total_bytes += size_bytes;
251 }
252
253 Ok(session_id)
254 }
255
256 /// Retrieves and removes a pending generation.
257 ///
258 /// Returns `None` if the session is not found or has expired.
259 /// This operation also performs lazy cleanup of expired sessions.
260 ///
261 /// # Examples
262 ///
263 /// ```
264 /// use mcp_execution_server::state::StateManager;
265 /// # use mcp_execution_server::types::PendingGeneration;
266 /// # use mcp_execution_core::{ServerId, ServerConfig};
267 /// # use mcp_execution_introspector::ServerInfo;
268 /// # use std::path::PathBuf;
269 ///
270 /// # async fn example(pending: PendingGeneration) {
271 /// let state = StateManager::new();
272 /// let session_id = state.store(pending).await.unwrap();
273 ///
274 /// let retrieved = state.take(session_id).await;
275 /// assert!(retrieved.is_some());
276 ///
277 /// // Second take returns None (already removed)
278 /// let second = state.take(session_id).await;
279 /// assert!(second.is_none());
280 /// # }
281 /// ```
282 pub async fn take(&self, session_id: Uuid) -> Option<PendingGeneration> {
283 let mut table = self.pending.write().await;
284 table.sweep_expired(self.clock.as_ref());
285
286 let entry = table.entries.remove(&session_id)?;
287 table.total_bytes -= entry.size_bytes;
288 drop(table);
289 let generation = entry.generation;
290
291 // Verify not expired (lock already released)
292 if generation.is_expired(self.clock.as_ref()) {
293 return None;
294 }
295
296 Some(generation)
297 }
298
299 /// Gets a pending generation without removing it.
300 ///
301 /// Returns `None` if the session is not found or has expired.
302 ///
303 /// # Examples
304 ///
305 /// ```
306 /// use mcp_execution_server::state::StateManager;
307 /// # use mcp_execution_server::types::PendingGeneration;
308 /// # use mcp_execution_core::{ServerId, ServerConfig};
309 /// # use mcp_execution_introspector::ServerInfo;
310 /// # use std::path::PathBuf;
311 ///
312 /// # async fn example(pending: PendingGeneration) {
313 /// let state = StateManager::new();
314 /// let session_id = state.store(pending).await.unwrap();
315 ///
316 /// // Get without removing
317 /// let peeked = state.get(session_id).await;
318 /// assert!(peeked.is_some());
319 ///
320 /// // Still available
321 /// let peeked_again = state.get(session_id).await;
322 /// assert!(peeked_again.is_some());
323 /// # }
324 /// ```
325 pub async fn get(&self, session_id: Uuid) -> Option<PendingGeneration> {
326 let table = self.pending.read().await;
327 let clock = self.clock.as_ref();
328 table
329 .entries
330 .get(&session_id)
331 .filter(|entry| !entry.generation.is_expired(clock))
332 .map(|entry| entry.generation.clone())
333 }
334
335 /// Returns the current pending session count (excluding expired).
336 ///
337 /// # Examples
338 ///
339 /// ```
340 /// use mcp_execution_server::state::StateManager;
341 ///
342 /// # async fn example() {
343 /// let state = StateManager::new();
344 /// assert_eq!(state.pending_count().await, 0);
345 /// # }
346 /// ```
347 pub async fn pending_count(&self) -> usize {
348 let table = self.pending.read().await;
349 let clock = self.clock.as_ref();
350 table
351 .entries
352 .values()
353 .filter(|entry| !entry.generation.is_expired(clock))
354 .count()
355 }
356
357 /// Cleans up all expired sessions.
358 ///
359 /// Returns the number of sessions that were removed.
360 ///
361 /// # Examples
362 ///
363 /// ```
364 /// use mcp_execution_server::state::StateManager;
365 ///
366 /// # async fn example() {
367 /// let state = StateManager::new();
368 /// let removed = state.cleanup_expired().await;
369 /// assert_eq!(removed, 0);
370 /// # }
371 /// ```
372 pub async fn cleanup_expired(&self) -> usize {
373 let mut table = self.pending.write().await;
374 let before = table.entries.len();
375 table.sweep_expired(self.clock.as_ref());
376 before - table.entries.len()
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use crate::clock::{SystemClock, TestClock};
384 use crate::types::PendingGeneration;
385 use mcp_execution_core::{ServerConfig, ServerId, ToolName};
386 use mcp_execution_introspector::ServerInfo;
387
388 fn create_test_pending() -> PendingGeneration {
389 create_test_pending_with_clock(&SystemClock)
390 }
391
392 fn create_test_pending_with_clock(clock: &dyn Clock) -> PendingGeneration {
393 use mcp_execution_introspector::{ServerCapabilities, ToolInfo};
394
395 let server_id = ServerId::new("test").unwrap();
396 let server_info = ServerInfo {
397 id: server_id.clone(),
398 name: "Test Server".to_string(),
399 version: "1.0.0".to_string(),
400 capabilities: ServerCapabilities {
401 supports_tools: true,
402 supports_resources: false,
403 supports_prompts: false,
404 },
405 tools: vec![ToolInfo {
406 name: ToolName::new("test_tool").unwrap(),
407 description: "Test tool".to_string(),
408 input_schema: serde_json::json!({}),
409 output_schema: None,
410 }],
411 };
412 let config = ServerConfig::builder()
413 .command("echo".to_string())
414 .build()
415 .unwrap();
416
417 PendingGeneration::new(server_id, server_info, config, None, clock)
418 }
419
420 /// Builds an already-expired pending generation by constructing it with a
421 /// clock fixed an hour in the past, instead of rewinding `expires_at`
422 /// after construction.
423 fn create_expired_pending() -> PendingGeneration {
424 let past_clock = TestClock::new(chrono::Utc::now() - chrono::Duration::hours(1));
425 create_test_pending_with_clock(&past_clock)
426 }
427
428 #[tokio::test]
429 async fn test_store_and_retrieve() {
430 let state = StateManager::new();
431 let pending = create_test_pending();
432
433 let session_id = state.store(pending.clone()).await.unwrap();
434 let retrieved = state.take(session_id).await;
435
436 assert!(retrieved.is_some());
437 let retrieved = retrieved.unwrap();
438 assert_eq!(retrieved.server_id, pending.server_id);
439 }
440
441 #[tokio::test]
442 async fn test_take_removes_session() {
443 let state = StateManager::new();
444 let pending = create_test_pending();
445
446 let session_id = state.store(pending).await.unwrap();
447
448 // First take succeeds
449 let first = state.take(session_id).await;
450 assert!(first.is_some());
451
452 // Second take returns None
453 let second = state.take(session_id).await;
454 assert!(second.is_none());
455 }
456
457 #[tokio::test]
458 async fn test_get_does_not_remove() {
459 let state = StateManager::new();
460 let pending = create_test_pending();
461
462 let session_id = state.store(pending).await.unwrap();
463
464 // Get multiple times
465 let first = state.get(session_id).await;
466 assert!(first.is_some());
467
468 let second = state.get(session_id).await;
469 assert!(second.is_some());
470
471 // Still available for take
472 let taken = state.take(session_id).await;
473 assert!(taken.is_some());
474 }
475
476 #[tokio::test]
477 async fn test_expired_session() {
478 let state = StateManager::new();
479 let pending = create_expired_pending();
480
481 let session_id = state.store(pending).await.unwrap();
482
483 // Should return None because expired
484 let retrieved = state.take(session_id).await;
485 assert!(retrieved.is_none());
486 }
487
488 #[tokio::test]
489 async fn test_pending_count() {
490 let state = StateManager::new();
491
492 assert_eq!(state.pending_count().await, 0);
493
494 let session_id = state.store(create_test_pending()).await.unwrap();
495 assert_eq!(state.pending_count().await, 1);
496
497 state.take(session_id).await;
498 assert_eq!(state.pending_count().await, 0);
499 }
500
501 #[tokio::test]
502 async fn test_cleanup_expired() {
503 let state = StateManager::new();
504
505 // Add valid session
506 state.store(create_test_pending()).await.unwrap();
507
508 // Add expired session
509 state.store(create_expired_pending()).await.unwrap();
510
511 assert_eq!(state.pending_count().await, 1); // Only valid session counts
512
513 let removed = state.cleanup_expired().await;
514 assert_eq!(removed, 1); // One expired session removed
515 }
516
517 /// Proves `StateManager` consults the clock it was constructed with (not a
518 /// hardcoded `SystemClock`): a session created and stored while the shared
519 /// clock is fresh must flip to expired across `get`/`pending_count`/
520 /// `cleanup_expired`/`take` once that same clock is moved past the TTL —
521 /// real wall-clock time barely advances during the test, so this would fail
522 /// if any of those call sites silently used `SystemClock` instead of
523 /// `self.clock`.
524 #[tokio::test]
525 async fn test_shared_clock_drives_expiry() {
526 let start = chrono::Utc::now();
527 let clock = Arc::new(TestClock::new(start));
528 let state = StateManager::with_clock(Arc::clone(&clock) as Arc<dyn Clock>);
529
530 let pending = create_test_pending_with_clock(clock.as_ref());
531 let session_id = state.store(pending).await.unwrap();
532
533 // Fresh session is visible while the clock is still within the TTL window.
534 assert!(state.get(session_id).await.is_some());
535 assert_eq!(state.pending_count().await, 1);
536
537 // Jump the shared clock straight past the 30-minute boundary.
538 clock.set(
539 start
540 + chrono::Duration::minutes(PendingGeneration::DEFAULT_TIMEOUT_MINUTES)
541 + chrono::Duration::seconds(1),
542 );
543
544 assert!(
545 state.get(session_id).await.is_none(),
546 "expiry should track the injected clock, not Utc::now()"
547 );
548 assert_eq!(state.pending_count().await, 0);
549 assert_eq!(state.cleanup_expired().await, 1);
550 assert!(state.take(session_id).await.is_none());
551 }
552
553 #[tokio::test]
554 async fn test_concurrent_access() {
555 let state = Arc::new(StateManager::new());
556 let mut handles = vec![];
557
558 // Spawn 10 concurrent store operations
559 for i in 0..10 {
560 let state_clone = Arc::clone(&state);
561 handles.push(tokio::spawn(async move {
562 let mut pending = create_test_pending();
563 pending.server_id = ServerId::new(format!("server-{i}")).unwrap();
564 state_clone.store(pending).await
565 }));
566 }
567
568 // Wait for all operations to complete
569 for handle in handles {
570 handle.await.unwrap().unwrap();
571 }
572
573 assert_eq!(state.pending_count().await, 10);
574 }
575
576 #[tokio::test]
577 async fn test_lazy_cleanup_on_store() {
578 let state = StateManager::new();
579
580 // Store expired session directly
581 {
582 let generation = create_expired_pending();
583 let size_bytes = estimate_size_bytes(&generation);
584 let mut table = state.pending.write().await;
585 table.entries.insert(
586 Uuid::new_v4(),
587 PendingEntry {
588 generation,
589 size_bytes,
590 },
591 );
592 table.total_bytes += size_bytes;
593 }
594
595 // Store new session triggers cleanup
596 state.store(create_test_pending()).await.unwrap();
597
598 // Only the new session should remain
599 assert_eq!(state.pending_count().await, 1);
600 }
601
602 // ── Resource-exhaustion bounds (issue #198) ──────────────────────────────
603
604 #[tokio::test]
605 async fn test_store_rejects_when_at_capacity() {
606 let state = StateManager::new();
607 for _ in 0..MAX_PENDING_SESSIONS {
608 state.store(create_test_pending()).await.unwrap();
609 }
610
611 let result = state.store(create_test_pending()).await;
612
613 assert!(result.is_err());
614 assert!(
615 matches!(result, Err(StateError::AtCapacity { limit }) if limit == MAX_PENDING_SESSIONS)
616 );
617 }
618
619 #[tokio::test]
620 async fn test_store_accepts_up_to_exact_capacity() {
621 let state = StateManager::new();
622 for _ in 0..MAX_PENDING_SESSIONS - 1 {
623 state.store(create_test_pending()).await.unwrap();
624 }
625
626 let result = state.store(create_test_pending()).await;
627
628 assert!(result.is_ok());
629 assert_eq!(state.pending_count().await, MAX_PENDING_SESSIONS);
630 }
631
632 /// #198 S1 — the aggregate memory budget, not just the session count, must reject.
633 ///
634 /// Building enough real sessions to reach `MAX_TOTAL_PENDING_BYTES` (~1GB) would be a slow,
635 /// wasteful multi-hundred-MB allocation for every CI run, so this seeds `total_bytes`
636 /// directly to just below the cap instead — precisely exercising the same comparison
637 /// `store()` performs without materializing gigabytes of real session data.
638 #[tokio::test]
639 async fn test_store_rejects_when_would_exceed_memory_budget() {
640 let state = StateManager::new();
641 {
642 let mut table = state.pending.write().await;
643 table.total_bytes = MAX_TOTAL_PENDING_BYTES;
644 }
645
646 let result = state.store(create_test_pending()).await;
647
648 assert!(result.is_err());
649 assert!(
650 matches!(result, Err(StateError::MemoryBudgetExceeded { limit }) if limit == MAX_TOTAL_PENDING_BYTES)
651 );
652 }
653
654 #[tokio::test]
655 async fn test_store_accepts_at_exact_memory_budget() {
656 let state = StateManager::new();
657 let generation = create_test_pending();
658 let size_bytes = estimate_size_bytes(&generation);
659 {
660 let mut table = state.pending.write().await;
661 table.total_bytes = MAX_TOTAL_PENDING_BYTES - size_bytes;
662 }
663
664 let result = state.store(generation).await;
665
666 assert!(result.is_ok());
667 }
668
669 #[tokio::test]
670 async fn test_take_decrements_total_bytes() {
671 let state = StateManager::new();
672 let generation = create_test_pending();
673 let size_bytes = estimate_size_bytes(&generation);
674 assert!(size_bytes > 0);
675
676 let session_id = state.store(generation).await.unwrap();
677 assert_eq!(state.pending.read().await.total_bytes, size_bytes);
678
679 state.take(session_id).await;
680 assert_eq!(state.pending.read().await.total_bytes, 0);
681 }
682
683 #[tokio::test]
684 async fn test_sweep_expired_decrements_total_bytes() {
685 let state = StateManager::new();
686 state.store(create_expired_pending()).await.unwrap();
687
688 // The expired session was counted at store time...
689 assert!(state.pending.read().await.total_bytes > 0);
690
691 // ...and swept back out once `cleanup_expired` runs.
692 state.cleanup_expired().await;
693 assert_eq!(state.pending.read().await.total_bytes, 0);
694 }
695}