hypen_engine/portable/
session.rs1use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "kebab-case")]
18pub enum SessionPolicy {
19 KickOld,
21 RejectNew,
23 AllowMultiple,
25}
26
27impl Default for SessionPolicy {
28 fn default() -> Self {
29 SessionPolicy::KickOld
30 }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(tag = "kind", rename_all = "snake_case")]
36pub enum SessionEvent {
37 Connect { existing_connection_count: u32 },
40 TtlTick {
44 suspended_at_ms: u64,
45 now_ms: u64,
46 ttl_ms: u64,
47 },
48}
49
50#[derive(Debug, Clone, Default, Serialize, Deserialize)]
54pub struct SessionState {
55 pub policy: SessionPolicy,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(tag = "kind", rename_all = "snake_case")]
64pub enum SessionEffect {
65 AcceptConnection,
67 AcceptAndKickExisting,
70 RejectConnection,
73 ExpireSession,
76 RetainSession,
78}
79
80pub 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 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 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 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}