Skip to main content

protocol_droid/adapters/primary/transport/http/
session_store.rs

1//! The `Mcp-Session-Id` map: the server's half of the session lifecycle.
2//!
3//! # What this owns
4//!
5//! Three transitions, and the specification names all three:
6//!
7//! - `initialize` **inserts**. The server mints an id, hands it back in a
8//!   response header, and every later request quotes it.
9//! - every later request **looks up**.
10//! - `DELETE` **removes**, and the specification says the server "MUST NOT
11//!   respond to requests containing that session ID" afterwards.
12//!
13//! Between those, a lookup miss is [`None`](SessionStore::lookup), and the
14//! router turns that into `404`. That is deliberately a different outcome from
15//! a header that will not parse: `McpSessionId::from_str` fails *before* the
16//! store is consulted, so a malformed id is `400` and never becomes a `404`.
17//! The two are not interchangeable — the specification's own recovery is
18//! "on 404, start a new session", and a client whose header is malformed has
19//! a different problem to fix.
20//!
21//! # Why the lock never escapes
22//!
23//! Every method takes the lock, does one map operation, and returns something
24//! that is *not* a guard. Lookups hand back an [`Arc`] cloned out of the map,
25//! which is what makes it structurally impossible for a caller to hold the
26//! lock across an `.await`: the guard is a local, dropped at the end of the
27//! function, and the returned value has no lifetime tied to `self`. A handler
28//! that receives an `Arc<S>` can hold it for as long as it likes, including
29//! across suspension points, and the store stays available to every other
30//! request. Returning `&S` instead would push the same constraint on every
31//! caller and be wrong on exactly the one that matters.
32//!
33//! [`concurrent_lookups_never_block_one_another`](self::tests::concurrent_lookups_never_block_one_another)
34//! is the test that holds the `Send + Sync` claim — the router puts this in
35//! shared state and a type that is neither would not compile there.
36//!
37//! # Why there is no capacity ceiling
38//!
39//! Because a ceiling on this map has exactly one honest eviction rule — drop
40//! the oldest session — and that rule is indistinguishable, from the client,
41//! from a server that forgot the session. Both produce `404`, and both reach
42//! the client as "start a new session", so a load spike silently destroys live
43//! state that the client believes it still owns. Refusing to open the
44//! thousand-and-first session is loud and fixable; evicting the first is quiet
45//! and is not.
46//!
47//! Idle reaping, by contrast, is a different shape: a session that has been
48//! quiet for longer than an operator-chosen idle window is not a session a
49//! client is still driving, and a server that evicts it produces a `404` on
50//! the *next* request — which is exactly what an evicted-after-DELETE
51//! session already produces. The two are not distinguishable from the
52//! client and were never going to be. A deployment that wants a bound
53//! constructs the store with
54//! [`with_idle_reaper`](SessionStore::with_idle_reaper) and calls
55//! [`reap_idle`](SessionStore::reap_idle) from a background task with the
56//! operator-chosen `max_age`; the store itself does not start a task,
57//! because a background task started inside a constructor is one a
58//! deployment cannot stop.
59//!
60//! Note what that leaves out: unlike
61//! [`InMemoryIdempotencyStore`](crate::adapters::secondary::InMemoryIdempotencyStore),
62//! these keys are not peer-chosen. Nobody can make the server allocate a
63//! session except by successfully completing an `initialize`, which is the
64//! one operation that already does work worth bounding elsewhere.
65//!
66//! # Why the id comes from the engine's `IdSource`
67//!
68//! Because the alternative is a process-global RNG call inside an adapter,
69//! which is the determinism rule. A test that wants to know which id a
70//! `initialize` will produce cannot arrange it if the adapter reaches for
71//! `rand::thread_rng()`; a test that runs the whole session lifecycle twice
72//! cannot compare anything if the second run's ids differ from the first's.
73//! [`IdSource`](crate::application::session::IdSource) is the seam the engine
74//! already owns for exactly this, and sharing it means an HTTP session and an
75//! engine session are numbered from the same sequence rather than two
76//! unrelated ones.
77//!
78//! # Protocol neutrality
79//!
80//! The store names a *header*, not a method. Nothing here knows what
81//! `initialize` is called, what it negotiates, or what a session does once it
82//! exists — the payload is a type parameter, so a protocol whose sessions are
83//! shaped differently instantiates this with a different `S` and changes
84//! nothing. See [`SessionStore`].
85
86use std::collections::HashMap;
87use std::sync::{Arc, Mutex, MutexGuard};
88use std::time::Duration;
89
90use thiserror::Error;
91
92use crate::application::session::{Clock, IdSource};
93use crate::domain::mcp::McpSessionId;
94use crate::domain::session::{Instant, SessionId};
95
96/// The sessions this server currently holds open.
97///
98/// Generic over what a session *is*, because this type is a map and a map does
99/// not need to know. The Streamable HTTP adapter instantiates it with whatever
100/// the router keeps per session — an engine handle, a protocol state machine, a
101/// cancellation token — and the `Arc` in [`lookup`](Self::lookup) is what lets
102/// a handler hold that state across an `.await` without borrowing the store.
103///
104/// # Example
105///
106/// ```
107/// use std::sync::Arc;
108/// use protocol_droid::adapters::primary::transport::http::SessionStore;
109/// use protocol_droid::application::session::IdSource;
110/// use protocol_droid::domain::session::state::SessionId;
111///
112/// // A source that hands out a fixed sequence, so the test can say which id it
113/// // expects instead of discovering it.
114/// struct Counting(core::sync::atomic::AtomicU64);
115/// impl IdSource for Counting {
116///     fn next_session_id(&self) -> SessionId {
117///         SessionId::new(
118///             self.0.fetch_add(1, core::sync::atomic::Ordering::SeqCst) + 1,
119///         )
120///     }
121/// }
122/// let ids: Arc<dyn IdSource + Send + Sync> =
123///     Arc::new(Counting(core::sync::atomic::AtomicU64::new(0)));
124///
125/// let store = SessionStore::new(ids);
126/// let id = store.open(Arc::new("the negotiated state"))?;
127/// // The id is a header value, so it round-trips through the header parser.
128/// assert_eq!(id.to_string().parse(), Ok(id));
129/// assert!(store.lookup(&id)?.is_some());
130///
131/// // DELETE removes it, and a later request is a miss rather than a leftover.
132/// assert!(store.terminate(&id)?.is_some());
133/// assert!(store.lookup(&id)?.is_none());
134/// # Ok::<(), protocol_droid::adapters::primary::transport::http::SessionStoreError>(())
135/// ```
136pub struct SessionStore<S> {
137    sessions: Mutex<HashMap<McpSessionId, Arc<S>>>,
138    /// The last time each session was opened or looked up.
139    ///
140    /// Populated only when the store was built through
141    /// [`with_idle_reaper`](Self::with_idle_reaper); a `None` here means
142    /// the store has no reaper and [`reap_idle`](Self::reap_idle) is a
143    /// no-op. A separate map, rather than a wrapper around `Arc<S>`, so
144    /// the existing `Arc<S>` shape is preserved for the common case
145    /// where reaping is off.
146    last_touched: Option<Mutex<HashMap<McpSessionId, Instant>>>,
147    ids: Arc<dyn IdSource + Send + Sync>,
148    /// The clock used to record `last_touched`. `None` when the reaper
149    /// is off, and reading it would force a `clock` to exist that
150    /// nothing uses.
151    clock: Option<Arc<dyn Clock + Send + Sync>>,
152}
153
154/// The two pieces of state a [`SessionStore`] with a reaper carries
155/// together: the timestamps map and the clock the reaper reads.
156///
157/// A type alias rather than spelling the pair at every call site,
158/// because the alternative is a long `&Mutex<HashMap<...>>` and a
159/// `&Arc<dyn Clock + ...>` on the same line, and clippy's
160/// `type_complexity` will rightly call that out the first time
161/// the signature is reused.
162type ReaperPair<'a> = (
163    &'a Mutex<HashMap<McpSessionId, Instant>>,
164    &'a Arc<dyn Clock + Send + Sync>,
165);
166
167impl<S> SessionStore<S> {
168    /// A store with no sessions, numbering them from `ids`.
169    ///
170    /// Takes the source rather than constructing one: the engine already has an
171    /// `IdSource` for the same reason, and two sources means two sequences for
172    /// what a reader will reasonably assume is one numbering.
173    ///
174    /// The store built through this constructor is unbounded. A deployment
175    /// that needs a bound constructs with
176    /// [`with_idle_reaper`](Self::with_idle_reaper) instead, which adds
177    /// the bookkeeping `reap_idle` reads.
178    #[must_use]
179    pub fn new(ids: Arc<dyn IdSource + Send + Sync>) -> Self {
180        Self {
181            sessions: Mutex::new(HashMap::new()),
182            last_touched: None,
183            ids,
184            clock: None,
185        }
186    }
187
188    /// A store that tracks when each session was last touched, so a
189    /// caller-driven [`reap_idle`](Self::reap_idle) can evict the ones
190    /// a client has stopped driving.
191    ///
192    /// The store does not start a reaper task on its own: a
193    /// background task started inside a constructor is one a deployment
194    /// cannot stop, and reaping intervals are an operator decision. The
195    /// caller is expected to call [`reap_idle`](Self::reap_idle) from
196    /// whichever loop it owns (a periodic timer, a `select!` on shutdown,
197    /// an external cron) with the `max_age` it considers idle.
198    ///
199    /// `clock` is the source of time the store reads. It is
200    /// injected rather than read from a global so a test can drive
201    /// reaping without sleeping; the rest of the crate already takes
202    /// the same shape for [`IdSource`](crate::application::session::IdSource).
203    #[must_use]
204    pub fn with_idle_reaper(
205        ids: Arc<dyn IdSource + Send + Sync>,
206        clock: Arc<dyn Clock + Send + Sync>,
207    ) -> Self {
208        Self {
209            sessions: Mutex::new(HashMap::new()),
210            last_touched: Some(Mutex::new(HashMap::new())),
211            ids,
212            clock: Some(clock),
213        }
214    }
215
216    /// Registers `session` under a freshly minted id and returns that id.
217    ///
218    /// The caller puts the id in the `Mcp-Session-Id` response header; this type
219    /// never formats it, because the buffer-based
220    /// [`McpSessionId::as_bytes`](crate::domain::mcp::McpSessionId::as_bytes) is
221    /// how the header gets filled without an allocation per response.
222    ///
223    /// When the store was built with
224    /// [`with_idle_reaper`](Self::with_idle_reaper), the new entry's
225    /// `last_touched` is the clock's current reading. The recording
226    /// happens under the same lock as the insertion, so a `reap_idle`
227    /// that runs concurrently with an `open` cannot see the session
228    /// without also seeing the timestamp that would spare it.
229    ///
230    /// # Errors
231    ///
232    /// [`SessionStoreError::DuplicateId`] if the injected source returned an id
233    /// this store is already holding. [`IdSource`] promises not to repeat one,
234    /// so a source that has wrapped or one that was seeded twice has broken its
235    /// contract, and overwriting would hand the second caller a session
236    /// belonging to the first. One id is tried: retrying a source that is
237    /// provably out of ids is a livelock with better manners, and the error
238    /// says what to do.
239    ///
240    /// [`SessionStoreError::Unavailable`] if the lock is poisoned.
241    pub fn open(&self, session: Arc<S>) -> Result<McpSessionId, SessionStoreError> {
242        let id = McpSessionId::new(self.ids.next_session_id());
243        self.insert(id, session)?;
244        Ok(id)
245    }
246
247    /// Opens a session under an id the caller has already taken.
248    ///
249    /// Separate from [`Self::open`] because a session's *peer* has to know its
250    /// own id before it is inserted — the peer is part of the value being
251    /// inserted, and everything the peer keys on (idempotency, in-flight
252    /// requests) reads the id from inside it. A store that allocated the id on
253    /// the way in could only hand the peer that id afterwards, by which point
254    /// the peer has already been constructed with the wrong one.
255    ///
256    /// # Errors
257    ///
258    /// As [`Self::open`]: [`SessionStoreError::DuplicateId`] if the id is
259    /// already held, [`SessionStoreError::Unavailable`] if the lock is poisoned.
260    pub fn open_with_id(
261        &self,
262        session: Arc<S>,
263        id: SessionId,
264    ) -> Result<McpSessionId, SessionStoreError> {
265        let id = McpSessionId::new(id);
266        self.insert(id, session)?;
267        Ok(id)
268    }
269
270    /// The next id this store would hand out.
271    ///
272    /// For a caller that must build a session *around* a known id before it
273    /// can hand that session to [`Self::open_with_id`]. The contract is the
274    /// same as the one `open` relies on: a source that repeats an id is caught
275    /// by the duplicate check there, so reading an id early is safe even
276    /// though nothing reserves it in the meantime.
277    ///
278    /// Two callers racing here get two different ids and both open; the loser
279    /// is refused by the duplicate check, which is the answer for a source that
280    /// repeats one.
281    pub fn next_id(&self) -> SessionId {
282        self.ids.next_session_id()
283    }
284
285    /// Inserts a session under an id already wrapped as a header id.
286    fn insert(&self, id: McpSessionId, session: Arc<S>) -> Result<(), SessionStoreError> {
287        // The guard is scoped to the block so it is released before the
288        // `Ok`, not at the end of the function: a caller that has just opened a
289        // session should not still be holding the lock by the time it starts
290        // doing the work the session exists for. The `record_touched` call
291        // happens after the sessions map's lock is dropped, so a poisoned
292        // touched-map lock cannot deadlock an `open` against another
293        // request that is mid-lookup on a different session.
294        {
295            let mut sessions = self.locked()?;
296            if sessions.contains_key(&id) {
297                return Err(SessionStoreError::DuplicateId);
298            }
299            sessions.insert(id, session);
300        }
301        self.record_touched(id)?;
302        Ok(())
303    }
304
305    /// The session under `id`, if this server still has it.
306    ///
307    /// A returned [`Arc`] is a snapshot of *reachability*, not of the session's
308    /// contents: a concurrent `DELETE` can remove the map entry while this
309    /// clone is still alive and being used. That is the intended semantics —
310    /// the in-flight request finishes against the session it already found,
311    /// and only the *next* request sees the `404` — so callers must not treat
312    /// a `Some` as a promise that the entry will still be there in a moment.
313    ///
314    /// When the store was built with
315    /// [`with_idle_reaper`](Self::with_idle_reaper), a successful
316    /// lookup updates the entry's `last_touched` to the clock's
317    /// current reading. The bookkeeping is what makes a session
318    /// "idle" mean "no request has hit it for the window", and a
319    /// request hitting it is the only signal the store has that
320    /// the client is still driving it.
321    ///
322    /// # Errors
323    ///
324    /// [`SessionStoreError::Unavailable`] if the lock is poisoned.
325    pub fn lookup(&self, id: &McpSessionId) -> Result<Option<Arc<S>>, SessionStoreError> {
326        let found = self.locked()?.get(id).cloned();
327        if found.is_some() {
328            self.record_touched(*id)?;
329        }
330        Ok(found)
331    }
332
333    /// Removes the session under `id`, returning it if it was there.
334    ///
335    /// A `None` here is the same `None` [`lookup`](Self::lookup) returns, and
336    /// the router maps both to the same outcome. Deciding between "terminate
337    /// this session" and "this endpoint does not offer termination here" is
338    /// the router's, because only the router knows what the endpoint offers.
339    ///
340    /// When the store was built with
341    /// [`with_idle_reaper`](Self::with_idle_reaper), the corresponding
342    /// `last_touched` is also removed: leaving it behind would be a
343    /// slow leak of a different shape (timestamps whose sessions
344    /// have already gone), and the next `reap_idle` would walk
345    /// past an entry that no longer has a session to evict.
346    ///
347    /// # Errors
348    ///
349    /// [`SessionStoreError::Unavailable`] if the lock is poisoned.
350    pub fn terminate(&self, id: &McpSessionId) -> Result<Option<Arc<S>>, SessionStoreError> {
351        let removed = self.locked()?.remove(id);
352        if removed.is_some() {
353            self.forget_touched(*id)?;
354        }
355        Ok(removed)
356    }
357
358    /// Evicts every session whose `last_touched` is older than `max_age`,
359    /// returning the ids that were reaped.
360    ///
361    /// The store built through [`new`](Self::new) has no reaper and
362    /// `reap_idle` is a no-op that returns an empty vector. The
363    /// store built through
364    /// [`with_idle_reaper`](Self::with_idle_reaper) records the time
365    /// on every `open` and `lookup`, and `reap_idle` compares it
366    /// against the clock's current reading.
367    ///
368    /// The caller drives reaping: a periodic timer, a `select!` on
369    /// shutdown, an external cron. There is no background task
370    /// started inside the store, because a background task started
371    /// inside a constructor is one a deployment cannot stop.
372    ///
373    /// # Errors
374    ///
375    /// [`SessionStoreError::Unavailable`] if the reaper's lock is
376    /// poisoned, which the same rule that refuses to recover the
377    /// sessions map also applies here: answering from state this
378    /// process cannot vouch for is how one request ends up served
379    /// another's.
380    pub fn reap_idle(&self, max_age: Duration) -> Result<Vec<McpSessionId>, SessionStoreError> {
381        let Some((touched, clock)) = self.reaper_pair() else {
382            return Ok(Vec::new());
383        };
384        let now = clock.now();
385        let mut stale: Vec<McpSessionId> = Vec::new();
386        // The two lock acquisitions in this function are
387        // deliberately separated: the touched map is read
388        // (to find stale ids), released, then the sessions map
389        // is held to do the eviction, then released, then the
390        // touched map is taken again to drop the timestamps
391        // for the ids that were evicted. Holding both at once
392        // would risk a deadlock against any future caller that
393        // takes them in the other order; the cost of the
394        // separation is two extra lock acquisitions per sweep,
395        // which is a budget the operator chose when they
396        // scheduled the sweep.
397        {
398            let touched = touched.lock().map_err(|_| SessionStoreError::Unavailable)?;
399            for (id, when) in touched.iter() {
400                // A session touched at `now` is not stale at
401                // `now`, even with a zero `max_age` — `>` is
402                // the right comparison, not `>=`, because the
403                // operator's "idle window" is a window, not a
404                // "before" cut. `saturating_sub` clamps a
405                // backwards clock to zero, so a clock that
406                // stepped back to before `when` cannot produce
407                // a negative elapsed time and is never stale.
408                if now.as_duration().saturating_sub(when.as_duration()) > max_age {
409                    stale.push(*id);
410                }
411            }
412        }
413        let mut reaped: Vec<McpSessionId> = Vec::with_capacity(stale.len());
414        {
415            let mut sessions = self.locked()?;
416            for id in stale {
417                if sessions.remove(&id).is_some() {
418                    reaped.push(id);
419                }
420            }
421        }
422        {
423            let mut touched = touched.lock().map_err(|_| SessionStoreError::Unavailable)?;
424            for id in &reaped {
425                touched.remove(id);
426            }
427        }
428        Ok(reaped)
429    }
430
431    /// How many sessions are open.
432    ///
433    /// # Errors
434    ///
435    /// [`SessionStoreError::Unavailable`] if the lock is poisoned. A made-up
436    /// number here would be worse than the error: this is the count an
437    /// operator reads to decide whether something is wrong.
438    pub fn len(&self) -> Result<usize, SessionStoreError> {
439        Ok(self.locked()?.len())
440    }
441
442    /// Whether no session is open.
443    ///
444    /// # Errors
445    ///
446    /// [`SessionStoreError::Unavailable`] if the lock is poisoned.
447    pub fn is_empty(&self) -> Result<bool, SessionStoreError> {
448        Ok(self.locked()?.is_empty())
449    }
450
451    /// One lock acquisition, shared by every operation.
452    ///
453    /// The guard's lifetime is this function's return type and nothing longer.
454    /// That is the whole of the "no lock across an await" rule: the guard is
455    /// consumed by the caller of `locked` inside a single expression, so it
456    /// cannot outlive the statement it was needed for.
457    fn locked(&self) -> Result<MutexGuard<'_, HashMap<McpSessionId, Arc<S>>>, SessionStoreError> {
458        self.sessions
459            .lock()
460            .map_err(|_| SessionStoreError::Unavailable)
461    }
462
463    /// The reaper's two pieces of state, when the store has a reaper.
464    ///
465    /// `None` when the store was built through [`new`](Self::new);
466    /// `Some` when built through
467    /// [`with_idle_reaper`](Self::with_idle_reaper). The two
468    /// references are returned together because the touched map and
469    /// the clock are always either both present or both absent —
470    /// a state the constructor's two-field literal pins at
471    /// construction time, so a `Some(touched)` here is also a
472    /// `Some(clock)`, and the `unreachable!` is unreachable.
473    fn reaper_pair(&self) -> Option<ReaperPair<'_>> {
474        let (touched, clock) = match (&self.last_touched, &self.clock) {
475            (Some(t), Some(c)) => (t, c),
476            (None, None) => return None,
477            // The two fields are set together in the constructor;
478            // any other combination is a violation of the
479            // invariant the constructor maintains.
480            _ => unreachable!("last_touched and clock must agree"),
481        };
482        Some((touched, clock))
483    }
484
485    /// Records `id` as touched at the clock's current reading.
486    ///
487    /// A no-op when the store has no reaper, so a caller can record
488    /// unconditionally without a `cfg(feature = ...)` or a runtime
489    /// check on the constructor.
490    ///
491    /// # Errors
492    ///
493    /// [`SessionStoreError::Unavailable`] if the touched map's lock
494    /// is poisoned.
495    fn record_touched(&self, id: McpSessionId) -> Result<(), SessionStoreError> {
496        let Some((touched, clock)) = self.reaper_pair() else {
497            return Ok(());
498        };
499        // The lock is scoped to this block so the guard is
500        // dropped before the `Ok` — clippy's
501        // `significant_drop_tightening` lint tracks a `MutexGuard`
502        // through the whole function and complains that the
503        // surrounding function (which does no further work) is
504        // holding a lock it does not need.
505        {
506            let mut touched = touched.lock().map_err(|_| SessionStoreError::Unavailable)?;
507            touched.insert(id, clock.now());
508        }
509        Ok(())
510    }
511
512    /// Forgets the `last_touched` entry for `id`, if one was kept.
513    ///
514    /// Called from [`terminate`](Self::terminate) so a session that
515    /// was explicitly removed does not leave a timestamp behind.
516    /// A no-op when the store has no reaper, by the same reasoning
517    /// as [`record_touched`](Self::record_touched).
518    ///
519    /// # Errors
520    ///
521    /// [`SessionStoreError::Unavailable`] if the touched map's lock
522    /// is poisoned.
523    fn forget_touched(&self, id: McpSessionId) -> Result<(), SessionStoreError> {
524        let Some((touched, _)) = self.reaper_pair() else {
525            return Ok(());
526        };
527        {
528            let mut touched = touched.lock().map_err(|_| SessionStoreError::Unavailable)?;
529            touched.remove(&id);
530        }
531        Ok(())
532    }
533}
534
535impl<S> core::fmt::Debug for SessionStore<S> {
536    /// Prints the type's name and nothing else.
537    ///
538    /// Deliberately opaque, and the omission is the point: a derived `Debug`
539    /// on this struct would print the whole map, which is a table of session
540    /// ids — and the project does not put a full session id in a log line.
541    /// A `{:?}` in a log statement must not become a way around that.
542    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
543        formatter
544            .debug_struct("SessionStore")
545            .finish_non_exhaustive()
546    }
547}
548
549/// Why a [`SessionStore`] operation could not be answered.
550///
551/// Not an `Error` for its own sake — it is one so a handler can `?` it into the
552/// same failure path as everything else the adapter refuses, and so
553/// `thiserror` writes the text that reaches a log line.
554///
555/// # Why no id appears in either message
556///
557/// Because `Display` output here is very likely to end up in a log, and the
558/// project does not log a full session id. Carrying the value inside the error
559/// would make the leak one careless `tracing::error!("{err}")` away, and the
560/// operator debugging a duplicate id wants to know that it happened far more
561/// than they want to know which id it was — the source is the thing to inspect.
562#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
563pub enum SessionStoreError {
564    /// A panic elsewhere unwound through the lock, so the map may be missing
565    /// an insert it was midway through.
566    ///
567    /// The store refuses rather than recovering, matching the idempotency
568    /// store's rule: answering from state this process cannot vouch for is how
569    /// one request ends up served another request's session.
570    #[error("the session store's lock is poisoned")]
571    Unavailable,
572
573    /// The injected [`IdSource`] returned an id that is already registered.
574    ///
575    /// [`IdSource`] requires that an id is not reused while either session can
576    /// still appear in the event log, so this means the source has wrapped or
577    /// was seeded twice. Replacing the source is the fix; the store will not
578    /// overwrite, because doing so silently transfers an established session to
579    /// whoever asked next.
580    #[error("the session id source returned an id that is already in use")]
581    DuplicateId,
582}
583
584#[cfg(test)]
585mod tests {
586    use std::sync::Arc;
587    use std::sync::atomic::{AtomicU64, Ordering};
588    use std::time::Duration;
589
590    use super::{SessionStore, SessionStoreError};
591    use crate::application::session::{Clock, IdSource};
592    use crate::domain::mcp::{McpSessionId, VISIBLE_ASCII_RANGE};
593    use crate::domain::session::{Instant, SessionId};
594
595    /// A source that hands out a fixed sequence and counts what it produced.
596    struct Counting(AtomicU64);
597
598    impl Counting {
599        fn new() -> Self {
600            Self(AtomicU64::new(0))
601        }
602
603        fn shared() -> Arc<dyn IdSource + Send + Sync> {
604            Arc::new(Self::new())
605        }
606
607        /// How many ids this source has been asked for.
608        fn issued(&self) -> u64 {
609            self.0.load(Ordering::SeqCst)
610        }
611    }
612
613    impl IdSource for Counting {
614        fn next_session_id(&self) -> SessionId {
615            SessionId::new(self.0.fetch_add(1, Ordering::SeqCst))
616        }
617    }
618
619    /// A source that always returns the same id, to exercise the collision
620    /// path that a well-behaved source can never reach.
621    struct Stuck(SessionId);
622
623    impl IdSource for Stuck {
624        fn next_session_id(&self) -> SessionId {
625            self.0
626        }
627    }
628
629    type TestError = Box<dyn std::error::Error>;
630
631    /// What a lookup thread can report.
632    ///
633    /// A named error rather than a boxed `dyn Error`, because the two failures
634    /// a thread has — the store refusing, and a session that was there at open
635    /// time being absent at lookup time — are different bugs and the message
636    /// has to survive the thread boundary to say which.
637    #[derive(Debug)]
638    enum ThreadFailure {
639        /// The store refused an operation.
640        Store(SessionStoreError),
641        /// A session that was open was not found.
642        Vanished,
643    }
644
645    impl From<SessionStoreError> for ThreadFailure {
646        fn from(error: SessionStoreError) -> Self {
647            Self::Store(error)
648        }
649    }
650
651    impl core::fmt::Display for ThreadFailure {
652        fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
653            match self {
654                Self::Store(error) => write!(formatter, "the store refused a lookup: {error}"),
655                Self::Vanished => write!(formatter, "a live session was not found"),
656            }
657        }
658    }
659
660    impl std::error::Error for ThreadFailure {}
661
662    /// The two failure modes name themselves in their `Display`, so a
663    /// `?`-chained test can distinguish them without a match. If this ever
664    /// fails, the two messages have been swapped or made identical.
665    #[test]
666    fn the_error_variants_are_distinguishable() {
667        assert_ne!(
668            SessionStoreError::Unavailable.to_string(),
669            SessionStoreError::DuplicateId.to_string()
670        );
671        assert!(
672            SessionStoreError::DuplicateId
673                .to_string()
674                .contains("already in use")
675        );
676    }
677
678    /// The store's ids must be ids the header parser accepts.
679    ///
680    /// The store and the wire format are separate types, and nothing in the
681    /// type system connects them: `McpSessionId` is constructed from a
682    /// `SessionId` by a `const fn` that cannot fail, so the only thing standing
683    /// between "the store minted an id" and "the client can quote it" is that
684    /// the two agree. This is the assertion that says they do — parse the
685    /// rendered header value back and compare the whole `Result`, so a failure
686    /// prints what the parser objected to rather than just a mismatch.
687    #[test]
688    fn a_minted_id_round_trips_through_the_header_parser() -> Result<(), TestError> {
689        let store = SessionStore::new(Counting::shared());
690        let id = store.open(Arc::new(42usize))?;
691        assert_eq!(id.to_string().parse::<McpSessionId>(), Ok(id));
692        Ok(())
693    }
694
695    /// The rendered header value is fixed-width and entirely legal in a header.
696    ///
697    /// Two separate claims, and they fail differently. A width that varied with
698    /// the value would let a length-sensitive intermediary treat `7` and
699    /// `0000000000000007` as different sessions; a byte outside visible ASCII
700    /// would not be a header at all. So the assertion checks both the exact
701    /// spelling and the byte range, rather than "it parsed".
702    #[test]
703    fn the_header_value_is_fixed_width_and_visible_ascii() -> Result<(), TestError> {
704        let store = SessionStore::new(Counting::shared());
705        for _ in 0..4 {
706            let id = store.open(Arc::new(()))?;
707            let rendered = id.to_string();
708            assert_eq!(
709                rendered.len(),
710                16,
711                "session id {rendered:?} is not sixteen characters"
712            );
713            assert!(
714                rendered
715                    .bytes()
716                    .all(|byte| VISIBLE_ASCII_RANGE.contains(&byte)),
717                "session id {rendered:?} carries a byte no header may contain"
718            );
719            assert!(
720                rendered.bytes().all(|byte| byte.is_ascii_hexdigit()),
721                "session id {rendered:?} is not hexadecimal"
722            );
723        }
724        Ok(())
725    }
726
727    /// Ids come from the injected source, and the store never numbers on its own.
728    ///
729    /// The claim is *substitution*: if the adapter reached for a process-global
730    /// RNG instead of the injected port, the sequence below would not be the
731    /// one the source produced. Asserting the exact first id is what makes a
732    /// `thread_rng` mutant fail here rather than merely being unlikely to be
733    /// noticed.
734    #[test]
735    fn ids_come_from_the_injected_source() -> Result<(), TestError> {
736        let source = Arc::new(Counting::new());
737        let store = SessionStore::new(source.clone());
738        let first = store.open(Arc::new(()))?;
739        let second = store.open(Arc::new(()))?;
740        assert_eq!(first, McpSessionId::new(SessionId::new(0)));
741        assert_eq!(second, McpSessionId::new(SessionId::new(1)));
742        assert_eq!(source.issued(), 2, "the store asked for a different count");
743        Ok(())
744    }
745
746    /// A lookup miss is `None`, and it is reached only after the header parsed.
747    ///
748    /// This is the 404-versus-400 boundary. `McpSessionId` is produced by
749    /// `from_str`, so a client that sends garbage never gets as far as
750    /// `lookup` — and the test says so by parsing a malformed header and
751    /// checking the failure is a parse error, *then* checking that a
752    /// well-formed header naming nothing is a miss. Conflating the two would
753    /// answer a broken header with "start a new session", which is advice that
754    /// cannot possibly help.
755    #[test]
756    fn a_miss_and_a_malformed_header_are_different_outcomes() -> Result<(), TestError> {
757        let store = SessionStore::new(Counting::shared());
758        let live = store.open(Arc::new(()))?;
759        store.terminate(&live)?;
760
761        // Well-formed, and unknown: a miss, which the router answers 404.
762        assert_eq!(store.lookup(&live)?, None);
763
764        // Not well-formed: the header parser refuses before the store is asked,
765        // which is the router's 400.
766        assert!("not-a-session".parse::<McpSessionId>().is_err());
767        assert!("".parse::<McpSessionId>().is_err());
768        Ok(())
769    }
770
771    /// `DELETE` of a session that is not there is also a miss, not a panic.
772    ///
773    /// The specification lets a server answer `405` on a `DELETE` it does not
774    /// offer, and either way the store's answer is the same `None` the router
775    /// turns into a decision. The claim under test is that removing twice is
776    /// well-defined and the second removal changes nothing.
777    #[test]
778    fn terminating_an_absent_session_is_a_miss() -> Result<(), TestError> {
779        let store = SessionStore::new(Counting::shared());
780        let id = store.open(Arc::new(()))?;
781        assert!(store.terminate(&id)?.is_some());
782        assert_eq!(store.terminate(&id)?, None);
783        assert!(store.is_empty()?);
784        Ok(())
785    }
786
787    /// A source that repeats an id must not transfer a live session to a new
788    /// caller.
789    ///
790    /// Overwriting here would be a session hijack: the second `initialize`
791    /// would be handed the first client's negotiated state, its event ring,
792    /// and its id, and neither side would find out until a request came back
793    /// addressed to someone else's conversation. `IdSource` forbids this, so
794    /// the store's job is to notice rather than to comply.
795    #[test]
796    fn a_repeated_id_refuses_instead_of_overwriting() -> Result<(), TestError> {
797        let store = SessionStore::new(Arc::new(Stuck(SessionId::new(7))));
798        let first = store.open(Arc::new("first"))?;
799        let second = store.open(Arc::new("second"));
800        assert_eq!(second, Err(SessionStoreError::DuplicateId));
801
802        // And the original session is untouched: one entry, still the first.
803        assert_eq!(store.len()?, 1);
804        let survivor = store.lookup(&first)?;
805        assert_eq!(survivor.as_deref(), Some(&"first"));
806        Ok(())
807    }
808
809    /// The store is usable from many threads at once, and no lookup waits on
810    /// another.
811    ///
812    /// Two claims in one test because they have the same failure mode to
813    /// detect: if the guard were part of a returned value, or if `S` were not
814    /// `Send + Sync`, this would not compile — and the router putting the store
815    /// in shared state needs it to. The assertion that every thread finds every
816    /// session also catches a store that accidentally hands out a shared
817    /// mutable view instead of a clone.
818    #[test]
819    fn concurrent_lookups_never_block_one_another() -> Result<(), TestError> {
820        const SESSIONS: usize = 8;
821        const THREADS: usize = 4;
822
823        let store = Arc::new(SessionStore::new(Counting::shared()));
824        let mut ids = Vec::new();
825        for _ in 0..SESSIONS {
826            ids.push(store.open(Arc::new(SESSIONS))?);
827        }
828
829        let threads: Vec<_> = (0..THREADS)
830            .map(|_| {
831                let store = Arc::clone(&store);
832                let ids = ids.clone();
833                std::thread::spawn(move || -> Result<(), ThreadFailure> {
834                    for _ in 0..64 {
835                        for id in &ids {
836                            if store.lookup(id)?.is_none() {
837                                return Err(ThreadFailure::Vanished);
838                            }
839                        }
840                    }
841                    Ok(())
842                })
843            })
844            .collect();
845        for thread in threads {
846            match thread.join() {
847                Ok(result) => result?,
848                Err(_) => return Err("a lookup thread panicked".into()),
849            }
850        }
851
852        assert_eq!(store.len()?, SESSIONS);
853        Ok(())
854    }
855
856    /// `len` and `is_empty` describe the same map the other methods mutate.
857    ///
858    /// Without this, an adapter could report a healthy zero-session server
859    /// while refusing every request, and the operator would be looking at the
860    /// wrong number.
861    #[test]
862    fn the_counters_track_the_operations() -> Result<(), TestError> {
863        let store = SessionStore::new(Counting::shared());
864        assert!(store.is_empty()?);
865        assert_eq!(store.len()?, 0);
866
867        let first = store.open(Arc::new(()))?;
868        let second = store.open(Arc::new(()))?;
869        assert_eq!(store.len()?, 2);
870        assert!(!store.is_empty()?);
871
872        store.terminate(&first)?;
873        assert_eq!(store.len()?, 1);
874        store.terminate(&second)?;
875        assert!(store.is_empty()?);
876        Ok(())
877    }
878
879    /// A clock the test drives by hand, so reaping can expire
880    /// without a real-time wait.
881    ///
882    /// The clock is `Arc<Stepping>`-shaped: the test owns the inner
883    /// `AtomicU64`, the store holds an `Arc<dyn Clock>` to the same
884    /// value, and `advance` mutates the time the store reads.
885    struct Stepping {
886        millis: AtomicU64,
887    }
888
889    impl Stepping {
890        fn new() -> Arc<Self> {
891            Arc::new(Self {
892                millis: AtomicU64::new(0),
893            })
894        }
895
896        fn advance(&self, by: Duration) {
897            let added = u64::try_from(by.as_millis()).unwrap_or(u64::MAX);
898            self.millis.fetch_add(added, Ordering::Relaxed);
899        }
900    }
901
902    impl Clock for Stepping {
903        fn now(&self) -> Instant {
904            Instant::from_duration(Duration::from_millis(self.millis.load(Ordering::Relaxed)))
905        }
906    }
907
908    impl Clock for Arc<Stepping> {
909        fn now(&self) -> Instant {
910            <Stepping as Clock>::now(self.as_ref())
911        }
912    }
913
914    /// A store built through `new` has no reaper.
915    ///
916    /// `reap_idle` returns an empty vector rather than tripping on
917    /// the absent clock, so an operator who never wired one in
918    /// does not get a panic on a sweep call.
919    #[test]
920    fn a_store_without_a_reaper_is_a_no_op_for_reap_idle() -> Result<(), TestError> {
921        let store = SessionStore::new(Counting::shared());
922        let id = store.open(Arc::new(()))?;
923        assert!(store.reap_idle(Duration::from_millis(0))?.is_empty());
924        assert_eq!(store.len()?, 1, "no reaper means no eviction");
925        let _ = id;
926        Ok(())
927    }
928
929    /// Sessions whose `last_touched` is older than the threshold
930    /// are removed; live ones are not.
931    ///
932    /// Three sessions are opened, the clock advances past the
933    /// threshold, only one is touched (a `lookup` updates the
934    /// timestamp), and `reap_idle` removes the other two.
935    #[test]
936    fn reap_idle_evicts_sessions_older_than_the_threshold() -> Result<(), TestError> {
937        let clock = Stepping::new();
938        let store = SessionStore::with_idle_reaper(Counting::shared(), clock.clone());
939        let stale_a = store.open(Arc::new(()))?;
940        let stale_b = store.open(Arc::new(()))?;
941        let live = store.open(Arc::new(()))?;
942
943        // The clock now reads 0; everything opened at 0 is fresh.
944        assert!(store.reap_idle(Duration::from_secs(60))?.is_empty());
945        assert_eq!(store.len()?, 3);
946
947        // Move the clock past the threshold for `stale_a` and
948        // `stale_b`, then touch `live` to refresh it.
949        clock.advance(Duration::from_secs(120));
950        assert!(
951            store.lookup(&live)?.is_some(),
952            "the live session should still be in the store"
953        );
954
955        // Now the threshold is 60s; `stale_a` and `stale_b` are
956        // 120s old, `live` is 0s old.
957        let reaped = store.reap_idle(Duration::from_secs(60))?;
958        let reaped_set: std::collections::HashSet<McpSessionId> = reaped.into_iter().collect();
959        assert!(reaped_set.contains(&stale_a));
960        assert!(reaped_set.contains(&stale_b));
961        assert!(!reaped_set.contains(&live));
962        assert_eq!(store.len()?, 1);
963        assert!(
964            store.lookup(&live)?.is_some(),
965            "the live session must survive the sweep"
966        );
967        assert!(store.lookup(&stale_a)?.is_none());
968        assert!(store.lookup(&stale_b)?.is_none());
969        Ok(())
970    }
971
972    /// A subsequent `reap_idle` after the clock has not moved
973    /// is a no-op, even with a zero threshold.
974    ///
975    /// The first sweep removes what was stale; the second one
976    /// has nothing left to remove, and the count of returns
977    /// (the number reaped) reflects that.
978    #[test]
979    fn reap_idle_is_idempotent_under_a_steady_clock() -> Result<(), TestError> {
980        let clock = Stepping::new();
981        let store = SessionStore::with_idle_reaper(Counting::shared(), clock.clone());
982        let _id = store.open(Arc::new(()))?;
983        clock.advance(Duration::from_secs(120));
984        let first = store.reap_idle(Duration::from_secs(60))?;
985        assert_eq!(first.len(), 1);
986        let second = store.reap_idle(Duration::from_secs(60))?;
987        assert!(second.is_empty(), "the second sweep has nothing to do");
988        Ok(())
989    }
990
991    /// `terminate` removes the session *and* its `last_touched`
992    /// entry, so a later `reap_idle` does not walk a stale
993    /// timestamp that no longer has a session to evict.
994    #[test]
995    fn terminate_clears_the_touched_entry() -> Result<(), TestError> {
996        let clock = Stepping::new();
997        let store = SessionStore::with_idle_reaper(Counting::shared(), clock.clone());
998        let id = store.open(Arc::new(()))?;
999        store.terminate(&id)?;
1000        clock.advance(Duration::from_secs(120));
1001        // A reaper that walked timestamps only would find a
1002        // `last_touched` for `id` here and try to evict a
1003        // session that is already gone. The bookkeeping that
1004        // `terminate` does is what prevents the dangling
1005        // timestamp.
1006        let reaped = store.reap_idle(Duration::from_secs(60))?;
1007        assert!(reaped.is_empty());
1008        assert_eq!(store.len()?, 0);
1009        Ok(())
1010    }
1011
1012    /// `lookup` is the signal the store has that the client is
1013    /// still driving a session. A session that is repeatedly
1014    /// looked up survives indefinitely, no matter how long the
1015    /// clock runs.
1016    #[test]
1017    fn a_looked_up_session_never_becomes_stale() -> Result<(), TestError> {
1018        let clock = Stepping::new();
1019        let store = SessionStore::with_idle_reaper(Counting::shared(), clock.clone());
1020        let id = store.open(Arc::new(()))?;
1021        for _ in 0..16 {
1022            clock.advance(Duration::from_secs(60));
1023            assert!(store.lookup(&id)?.is_some());
1024        }
1025        // The session is 16 × 60s = 960s old by the clock, but
1026        // every step touched it. A zero-threshold sweep is the
1027        // harshest test: a session that is not strictly newer
1028        // than the previous reading is stale. The latest
1029        // `lookup` is the one the reaper sees.
1030        let reaped = store.reap_idle(Duration::from_secs(0))?;
1031        assert!(
1032            reaped.is_empty(),
1033            "a session touched at `now` is not stale at `now`"
1034        );
1035        Ok(())
1036    }
1037}