Skip to main content

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}