Skip to main content

hypen_engine/portable/
session.rs

1//! Session state-machine decisions.
2//!
3//! Each host SDK owns a `SessionManager` that holds native connection
4//! handles and timers. This module owns the two pure decisions those
5//! managers make on every event: what to do with a new connection
6//! given the configured concurrent-connection policy, and whether a
7//! suspended session's TTL has elapsed. Transport and bookkeeping stay
8//! in the host; the decision lives here.
9
10use serde::{Deserialize, Serialize};
11
12/// How a new connection to an existing session should be handled.
13///
14/// Matches the `ConcurrentPolicy` / `ConcurrentKickOld` / `.kickOld`
15/// enums in the Go, Swift, Kotlin, Rust SDKs and TypeScript core.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "kebab-case")]
18pub enum SessionPolicy {
19    /// Newest connection wins; kick all older ones. (Default.)
20    KickOld,
21    /// Reject the new connection if any existing one is still attached.
22    RejectNew,
23    /// Allow unlimited concurrent connections for a single session.
24    AllowMultiple,
25}
26
27impl Default for SessionPolicy {
28    fn default() -> Self {
29        SessionPolicy::KickOld
30    }
31}
32
33/// Input event for [`session_step`].
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(tag = "kind", rename_all = "snake_case")]
36pub enum SessionEvent {
37    /// A new connection attempt arrived. `existing_connection_count` is
38    /// the number of connections currently attached to the session.
39    Connect { existing_connection_count: u32 },
40    /// The TTL timer fired for a suspended session. `suspended_at_ms`
41    /// is when the session was suspended, `now_ms` is the current
42    /// wall-clock time, and `ttl_ms` is the configured TTL.
43    TtlTick {
44        suspended_at_ms: u64,
45        now_ms: u64,
46        ttl_ms: u64,
47    },
48}
49
50/// Minimal state carried across `session_step` calls. Currently only
51/// holds the policy; kept as a struct so hosts can round-trip it
52/// through JSON without caring about the enum representation.
53#[derive(Debug, Clone, Default, Serialize, Deserialize)]
54pub struct SessionState {
55    pub policy: SessionPolicy,
56}
57
58/// Effect the host must apply as a result of a [`session_step`] call.
59///
60/// The decision is pure; the effect describes what the *host* should do
61/// with its native connection handles and timers.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(tag = "kind", rename_all = "snake_case")]
64pub enum SessionEffect {
65    /// Accept the new connection.
66    AcceptConnection,
67    /// Accept the new connection and kick every existing one attached
68    /// to the same session.
69    AcceptAndKickExisting,
70    /// Reject the new connection; an existing one already holds the
71    /// session under a `RejectNew` policy.
72    RejectConnection,
73    /// The suspended session's TTL has elapsed — the host should
74    /// drop the pending entry and fire its `on_expire` callback.
75    ExpireSession,
76    /// TTL has not yet elapsed; the host can rearm or ignore.
77    RetainSession,
78}
79
80/// Decide what to do in response to a session event.
81///
82/// Always returns exactly one effect; hosts apply it to their native
83/// state. No I/O is performed here — the host owns timers, sockets,
84/// and connection maps.
85pub fn session_step(state: &SessionState, event: &SessionEvent) -> SessionEffect {
86    match event {
87        SessionEvent::Connect {
88            existing_connection_count,
89        } => match state.policy {
90            SessionPolicy::AllowMultiple => SessionEffect::AcceptConnection,
91            SessionPolicy::RejectNew => {
92                if *existing_connection_count > 0 {
93                    SessionEffect::RejectConnection
94                } else {
95                    SessionEffect::AcceptConnection
96                }
97            }
98            SessionPolicy::KickOld => {
99                if *existing_connection_count > 0 {
100                    SessionEffect::AcceptAndKickExisting
101                } else {
102                    SessionEffect::AcceptConnection
103                }
104            }
105        },
106        SessionEvent::TtlTick {
107            suspended_at_ms,
108            now_ms,
109            ttl_ms,
110        } => {
111            // saturating_sub handles clock skew and overflow.
112            let elapsed = now_ms.saturating_sub(*suspended_at_ms);
113            if elapsed >= *ttl_ms {
114                SessionEffect::ExpireSession
115            } else {
116                SessionEffect::RetainSession
117            }
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn kick_old_on_first_connection_accepts() {
128        let s = SessionState {
129            policy: SessionPolicy::KickOld,
130        };
131        let e = SessionEvent::Connect {
132            existing_connection_count: 0,
133        };
134        assert_eq!(session_step(&s, &e), SessionEffect::AcceptConnection);
135    }
136
137    #[test]
138    fn kick_old_on_second_connection_kicks() {
139        let s = SessionState {
140            policy: SessionPolicy::KickOld,
141        };
142        let e = SessionEvent::Connect {
143            existing_connection_count: 1,
144        };
145        assert_eq!(session_step(&s, &e), SessionEffect::AcceptAndKickExisting);
146    }
147
148    #[test]
149    fn reject_new_rejects_when_occupied() {
150        let s = SessionState {
151            policy: SessionPolicy::RejectNew,
152        };
153        assert_eq!(
154            session_step(
155                &s,
156                &SessionEvent::Connect {
157                    existing_connection_count: 1
158                }
159            ),
160            SessionEffect::RejectConnection
161        );
162        assert_eq!(
163            session_step(
164                &s,
165                &SessionEvent::Connect {
166                    existing_connection_count: 0
167                }
168            ),
169            SessionEffect::AcceptConnection
170        );
171    }
172
173    #[test]
174    fn allow_multiple_always_accepts() {
175        let s = SessionState {
176            policy: SessionPolicy::AllowMultiple,
177        };
178        for n in [0, 1, 2, 10, 100] {
179            assert_eq!(
180                session_step(
181                    &s,
182                    &SessionEvent::Connect {
183                        existing_connection_count: n
184                    }
185                ),
186                SessionEffect::AcceptConnection
187            );
188        }
189    }
190
191    #[test]
192    fn ttl_not_yet_elapsed_retains() {
193        let s = SessionState::default();
194        let e = SessionEvent::TtlTick {
195            suspended_at_ms: 1_000,
196            now_ms: 1_500,
197            ttl_ms: 1_000,
198        };
199        assert_eq!(session_step(&s, &e), SessionEffect::RetainSession);
200    }
201
202    #[test]
203    fn ttl_elapsed_expires() {
204        let s = SessionState::default();
205        let e = SessionEvent::TtlTick {
206            suspended_at_ms: 1_000,
207            now_ms: 2_001,
208            ttl_ms: 1_000,
209        };
210        assert_eq!(session_step(&s, &e), SessionEffect::ExpireSession);
211    }
212
213    #[test]
214    fn ttl_clock_skew_does_not_underflow() {
215        // If now_ms < suspended_at_ms (clock went backwards), saturating
216        // subtraction clamps to zero and we retain.
217        let s = SessionState::default();
218        let e = SessionEvent::TtlTick {
219            suspended_at_ms: 2_000,
220            now_ms: 1_000,
221            ttl_ms: 1_000,
222        };
223        assert_eq!(session_step(&s, &e), SessionEffect::RetainSession);
224    }
225
226    #[test]
227    fn json_roundtrip_for_cross_host_transport() {
228        // Hosts serialise state+event through JSON. Round-tripping must
229        // preserve meaning so the WASM/UniFFI bindings can use the same
230        // string-in/string-out surface.
231        let state = SessionState {
232            policy: SessionPolicy::KickOld,
233        };
234        let event = SessionEvent::Connect {
235            existing_connection_count: 2,
236        };
237        let state_s = serde_json::to_string(&state).unwrap();
238        let event_s = serde_json::to_string(&event).unwrap();
239
240        let state2: SessionState = serde_json::from_str(&state_s).unwrap();
241        let event2: SessionEvent = serde_json::from_str(&event_s).unwrap();
242        assert_eq!(
243            session_step(&state2, &event2),
244            SessionEffect::AcceptAndKickExisting
245        );
246    }
247}