use core::fmt;
use std::time::Duration;
use thiserror::Error;
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SessionId(u64);
impl fmt::Debug for SessionId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("SessionId(…)")
}
}
impl SessionId {
#[must_use]
pub const fn new(value: u64) -> Self {
Self(value)
}
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}
impl fmt::Display for SessionId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "session#{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Hash)]
pub struct Instant(Duration);
impl Instant {
#[must_use]
pub const fn from_duration(since: Duration) -> Self {
Self(since)
}
#[must_use]
pub const fn as_duration(self) -> Duration {
self.0
}
#[must_use]
pub const fn since(self, earlier: Self) -> Duration {
self.0.saturating_sub(earlier.0)
}
#[must_use]
pub const fn advanced(self, by: Duration) -> Self {
Self(self.0.saturating_add(by))
}
}
impl fmt::Display for Instant {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}ms", self.0.as_millis())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum SessionState {
Uninitialised,
Initialising,
Operating,
Closing,
Closed,
Stateless,
}
impl SessionState {
#[must_use]
pub const fn admits_ordinary_work(self) -> bool {
matches!(self, Self::Operating | Self::Stateless)
}
#[must_use]
pub const fn is_stateless(self) -> bool {
matches!(self, Self::Stateless)
}
#[must_use]
pub const fn is_terminal(self) -> bool {
matches!(self, Self::Closed)
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Uninitialised => "uninitialised",
Self::Initialising => "initialising",
Self::Operating => "operating",
Self::Closing => "closing",
Self::Closed => "closed",
Self::Stateless => "stateless",
}
}
}
impl fmt::Display for SessionState {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LifecycleEvent {
HandshakeStarted,
HandshakeConfirmed,
WentStateless,
CloseRequested,
Finished,
}
impl LifecycleEvent {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::HandshakeStarted => "handshake-started",
Self::HandshakeConfirmed => "handshake-confirmed",
Self::WentStateless => "went-stateless",
Self::CloseRequested => "close-requested",
Self::Finished => "finished",
}
}
}
impl fmt::Display for LifecycleEvent {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum LifecycleError {
#[error("{event} is not legal on a closed session")]
Terminal {
event: LifecycleEvent,
},
#[error("{event} is not legal while the session is {state}")]
IllegalTransition {
state: SessionState,
event: LifecycleEvent,
},
}
impl LifecycleError {
#[must_use]
pub const fn is_final(self) -> bool {
matches!(self, Self::Terminal { .. })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Session {
id: SessionId,
state: SessionState,
opened_at: Instant,
transitions: u32,
}
impl Session {
#[must_use]
pub const fn new(id: SessionId, opened_at: Instant) -> Self {
Self {
id,
state: SessionState::Uninitialised,
opened_at,
transitions: 0,
}
}
#[must_use]
pub const fn stateless(id: SessionId, opened_at: Instant) -> Self {
Self {
id,
state: SessionState::Stateless,
opened_at,
transitions: 0,
}
}
#[must_use]
pub const fn id(&self) -> SessionId {
self.id
}
#[must_use]
pub const fn state(&self) -> SessionState {
self.state
}
#[must_use]
pub const fn opened_at(&self) -> Instant {
self.opened_at
}
#[must_use]
pub const fn transitions(&self) -> u32 {
self.transitions
}
#[must_use]
pub const fn admits_ordinary_work(&self) -> bool {
self.state.admits_ordinary_work()
}
pub const fn apply(&self, event: LifecycleEvent) -> Result<Self, LifecycleError> {
if self.state.is_terminal() {
return Err(LifecycleError::Terminal { event });
}
let state = match (self.state, event) {
(SessionState::Uninitialised, LifecycleEvent::HandshakeStarted) => {
SessionState::Initialising
}
(SessionState::Uninitialised, LifecycleEvent::WentStateless) => SessionState::Stateless,
(SessionState::Initialising, LifecycleEvent::HandshakeConfirmed) => {
SessionState::Operating
}
(
SessionState::Uninitialised
| SessionState::Initialising
| SessionState::Operating
| SessionState::Stateless,
LifecycleEvent::CloseRequested,
) => SessionState::Closing,
(SessionState::Closing, LifecycleEvent::Finished) => SessionState::Closed,
(state, event) => return Err(LifecycleError::IllegalTransition { state, event }),
};
Ok(Self {
id: self.id,
state,
opened_at: self.opened_at,
transitions: self.transitions.saturating_add(1),
})
}
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::{Instant, LifecycleError, LifecycleEvent, Session, SessionId, SessionState};
fn open() -> Session {
Session::new(
SessionId::new(7),
Instant::from_duration(std::time::Duration::ZERO),
)
}
#[test]
fn every_documented_edge_is_reachable() {
let cases: &[(SessionState, LifecycleEvent, SessionState)] = &[
(
SessionState::Uninitialised,
LifecycleEvent::HandshakeStarted,
SessionState::Initialising,
),
(
SessionState::Uninitialised,
LifecycleEvent::WentStateless,
SessionState::Stateless,
),
(
SessionState::Uninitialised,
LifecycleEvent::CloseRequested,
SessionState::Closing,
),
(
SessionState::Initialising,
LifecycleEvent::HandshakeConfirmed,
SessionState::Operating,
),
(
SessionState::Initialising,
LifecycleEvent::CloseRequested,
SessionState::Closing,
),
(
SessionState::Operating,
LifecycleEvent::CloseRequested,
SessionState::Closing,
),
(
SessionState::Stateless,
LifecycleEvent::CloseRequested,
SessionState::Closing,
),
(
SessionState::Closing,
LifecycleEvent::Finished,
SessionState::Closed,
),
];
for (from, event, to) in cases {
let session = Session {
state: *from,
..open()
};
assert_eq!(
session.apply(*event).map(|next| next.state()),
Ok(*to),
"{event} from {from} should be legal"
);
}
}
#[test]
fn closing_is_reachable_from_every_live_state_and_nothing_reaches_operating_from_closed()
-> Result<(), LifecycleError> {
for state in [
SessionState::Uninitialised,
SessionState::Initialising,
SessionState::Operating,
SessionState::Stateless,
] {
let session = Session { state, ..open() };
let closing = session.apply(LifecycleEvent::CloseRequested)?;
assert_eq!(closing.state(), SessionState::Closing, "from {state}");
let closed = closing.apply(LifecycleEvent::Finished)?;
assert_eq!(closed.state(), SessionState::Closed, "from {state}");
}
Ok(())
}
fn walked(events: &[LifecycleEvent]) -> Result<Session, LifecycleError> {
events
.iter()
.try_fold(open(), |session, event| session.apply(*event))
}
#[test]
fn a_refused_event_leaves_the_session_exactly_as_it_was() -> Result<(), LifecycleError> {
let operating = walked(&[
LifecycleEvent::HandshakeStarted,
LifecycleEvent::HandshakeConfirmed,
])?;
let before = operating.transitions();
let refused = operating.apply(LifecycleEvent::HandshakeConfirmed);
assert_matches::assert_matches!(
refused,
Err(LifecycleError::IllegalTransition {
state: SessionState::Operating,
event: LifecycleEvent::HandshakeConfirmed
})
);
assert_eq!(
operating.transitions(),
before,
"a refused event was counted"
);
assert!(operating.admits_ordinary_work());
Ok(())
}
#[test]
fn closed_is_terminal_for_every_event() -> Result<(), LifecycleError> {
let closed = walked(&[LifecycleEvent::CloseRequested, LifecycleEvent::Finished])?;
for event in [
LifecycleEvent::HandshakeStarted,
LifecycleEvent::HandshakeConfirmed,
LifecycleEvent::WentStateless,
LifecycleEvent::CloseRequested,
LifecycleEvent::Finished,
] {
let refused = closed.apply(event);
assert!(
matches!(refused, Err(LifecycleError::Terminal { .. })),
"{event} revived a closed session"
);
}
Ok(())
}
#[test]
fn only_a_closed_session_gives_a_final_refusal() -> Result<(), LifecycleError> {
let closed = walked(&[LifecycleEvent::CloseRequested, LifecycleEvent::Finished])?;
let terminal = closed.apply(LifecycleEvent::HandshakeStarted);
assert!(
matches!(terminal, Err(refusal) if refusal.is_final()),
"a closed session will never accept anything again: {terminal:?}"
);
let early = open().apply(LifecycleEvent::HandshakeConfirmed);
assert!(
matches!(early, Err(refusal) if !refusal.is_final()),
"an out-of-turn event is recoverable, not a dead end: {early:?}"
);
Ok(())
}
fn any_event() -> impl Strategy<Value = LifecycleEvent> {
prop_oneof![
Just(LifecycleEvent::HandshakeStarted),
Just(LifecycleEvent::HandshakeConfirmed),
Just(LifecycleEvent::WentStateless),
Just(LifecycleEvent::CloseRequested),
Just(LifecycleEvent::Finished),
]
}
proptest! {
#[test]
fn no_event_sequence_reaches_an_unmodelled_state(events in prop::collection::vec(any_event(), 0..24)) {
let mut session = open();
for event in &events {
if let Ok(next) = session.apply(*event) {
session = next;
}
prop_assert!(matches!(
session.state(),
SessionState::Uninitialised
| SessionState::Initialising
| SessionState::Operating
| SessionState::Closing
| SessionState::Closed
| SessionState::Stateless
));
prop_assert!(session.transitions() as usize <= events.len());
}
}
#[test]
fn closed_is_absorbing(
prefix in prop::collection::vec(any_event(), 0..12),
suffix in prop::collection::vec(any_event(), 0..12),
) {
let mut session = open();
for event in &prefix {
if let Ok(next) = session.apply(*event) {
session = next;
}
}
for event in &suffix {
if let Ok(next) = session.apply(*event) {
session = next;
}
}
if session.state().is_terminal() {
prop_assert!(!session.admits_ordinary_work());
for event in &suffix {
prop_assert!(session.apply(*event).is_err());
}
}
}
#[test]
fn closing_does_not_reverse(
prefix in prop::collection::vec(any_event(), 0..12),
suffix in prop::collection::vec(any_event(), 0..12),
) {
let mut session = open();
for event in prefix {
if let Ok(next) = session.apply(event) {
session = next;
}
}
if !matches!(session.state(), SessionState::Closing) {
return Ok(());
}
for event in suffix {
if let Ok(next) = session.apply(event) {
session = next;
}
}
prop_assert!(matches!(
session.state(),
SessionState::Closing | SessionState::Closed
));
}
}
#[test]
fn a_backwards_clock_reports_no_elapsed_time_rather_than_wrapping() {
let later = Instant::from_duration(std::time::Duration::from_secs(10));
let earlier = Instant::from_duration(std::time::Duration::from_secs(1));
assert_eq!(later.since(earlier).as_secs(), 9);
assert_eq!(earlier.since(later), std::time::Duration::ZERO);
}
#[test]
fn advancing_saturates_instead_of_overflowing() {
let base = Instant::from_duration(std::time::Duration::MAX);
let advanced = base.advanced(std::time::Duration::from_secs(1));
assert_eq!(advanced, base);
}
#[test]
fn ordinary_work_is_admitted_only_where_the_specification_says() {
assert!(!SessionState::Uninitialised.admits_ordinary_work());
assert!(!SessionState::Initialising.admits_ordinary_work());
assert!(SessionState::Operating.admits_ordinary_work());
assert!(!SessionState::Closing.admits_ordinary_work());
assert!(!SessionState::Closed.admits_ordinary_work());
assert!(SessionState::Stateless.admits_ordinary_work());
}
#[test]
fn debug_never_renders_the_value() {
for value in [0_u64, 7, 42, u64::from(u32::MAX), u64::MAX] {
let rendered = format!("{:?}", SessionId::new(value));
assert_eq!(
rendered, "SessionId(…)",
"value {value} must not reach Debug"
);
assert!(
!rendered.contains(&value.to_string()),
"Debug leaked the value {value}: {rendered}"
);
}
}
#[test]
fn two_ids_are_indistinguishable_in_debug() {
let rendered = format!("{:?} {:?}", SessionId::new(11), SessionId::new(12));
assert_eq!(rendered, "SessionId(…) SessionId(…)");
assert!(
!rendered.contains('1'),
"a digit escaped into Debug: {rendered}"
);
}
#[test]
fn display_is_still_the_full_identity() {
assert_eq!(SessionId::new(42).to_string(), "session#42");
}
#[test]
fn the_mcp_session_id_inherits_the_redaction() {
let mcp = crate::domain::McpSessionId::new(SessionId::new(0xDEAD_BEEF));
let rendered = format!("{mcp:?}");
assert!(
!rendered.contains("deadbeef") && !rendered.contains("3735928559"),
"the MCP session id leaked its value through Debug: {rendered}"
);
}
}