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}