Skip to main content

ironfix_session/
state.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 27/1/26
5******************************************************************************/
6
7//! Session state machine using the typestate pattern.
8//!
9//! This module implements a compile-time checked state machine for FIX sessions.
10//! State transitions are enforced by the type system, preventing invalid operations.
11//!
12//! # States carry their data
13//!
14//! [`Session`] stores its state value, not a `PhantomData<S>`, so the data a
15//! state is defined by is reachable: when the session is in [`LogonSent`] it
16//! *has* the instant the Logon went out, and only then. That is what lets a
17//! caller enforce the logon and logout timeouts from the state machine rather
18//! than tracking deadlines beside it — `ironfix-engine`'s reactor times its
19//! Logout out through `Session<LogoutPending>::sent_at`.
20//!
21//! The states with no data ([`Disconnected`], [`Connecting`], [`Active`]) stay
22//! zero-sized, so the typestate still costs nothing for them.
23
24use std::time::Instant;
25
26/// Marker trait for session states.
27pub trait SessionState: private::Sealed {}
28
29mod private {
30    pub trait Sealed {}
31}
32
33/// Disconnected state - no connection established.
34#[derive(Debug, Clone, Copy)]
35pub struct Disconnected;
36
37impl private::Sealed for Disconnected {}
38impl SessionState for Disconnected {}
39
40/// Connecting state - TCP connection in progress.
41#[derive(Debug, Clone, Copy)]
42pub struct Connecting;
43
44impl private::Sealed for Connecting {}
45impl SessionState for Connecting {}
46
47/// LogonSent state - Logon message sent, awaiting response.
48#[derive(Debug, Clone, Copy)]
49pub struct LogonSent {
50    /// Time when Logon was sent, for the `logon_timeout`.
51    pub sent_at: Instant,
52}
53
54impl private::Sealed for LogonSent {}
55impl SessionState for LogonSent {}
56
57/// LogonReceived state - Logon received from counterparty (acceptor side),
58/// pending authentication.
59#[derive(Debug, Clone, Copy)]
60pub struct LogonReceived {
61    /// Time when the Logon was received, for the authentication deadline.
62    pub received_at: Instant,
63}
64
65impl private::Sealed for LogonReceived {}
66impl SessionState for LogonReceived {}
67
68/// Active state - session is fully established.
69#[derive(Debug, Clone, Copy)]
70pub struct Active;
71
72impl private::Sealed for Active {}
73impl SessionState for Active {}
74
75/// Resending state - processing a resend request.
76#[derive(Debug, Clone, Copy)]
77pub struct Resending {
78    /// Begin sequence number of the gap, `BeginSeqNo` (7).
79    pub begin_seq: u64,
80    /// End sequence number of the gap, `EndSeqNo` (16).
81    ///
82    /// `0` carries the FIX convention "through the last message sent"
83    /// (`doc/fix_operations.md`, "Resend Request"), not an empty range.
84    pub end_seq: u64,
85}
86
87impl private::Sealed for Resending {}
88impl SessionState for Resending {}
89
90/// LogoutPending state - Logout sent, awaiting confirmation.
91#[derive(Debug, Clone, Copy)]
92pub struct LogoutPending {
93    /// Time when Logout was sent, for the `logout_timeout`.
94    pub sent_at: Instant,
95}
96
97impl private::Sealed for LogoutPending {}
98impl SessionState for LogoutPending {}
99
100/// Session wrapper with typestate for compile-time state checking.
101///
102/// The type parameter `S` represents the current session state, and the value
103/// of that state is stored: see the module documentation.
104#[derive(Debug)]
105pub struct Session<S: SessionState> {
106    /// Session identifier.
107    pub session_id: String,
108    /// The current state and its data.
109    state: S,
110}
111
112impl<S: SessionState> Session<S> {
113    /// Returns the session identifier.
114    #[must_use]
115    pub fn session_id(&self) -> &str {
116        &self.session_id
117    }
118
119    /// Returns the current state and its data.
120    #[must_use]
121    pub const fn state(&self) -> &S {
122        &self.state
123    }
124
125    /// Moves to `next`, carrying the session identity across.
126    ///
127    /// Private: the only way to reach a state is through the transition that
128    /// names it, which is what keeps an illegal transition uncompilable.
129    fn transition<N: SessionState>(self, next: N) -> Session<N> {
130        Session {
131            session_id: self.session_id,
132            state: next,
133        }
134    }
135}
136
137impl Session<Disconnected> {
138    /// Creates a new disconnected session.
139    ///
140    /// # Arguments
141    /// * `session_id` - Unique identifier for this session
142    #[must_use]
143    pub fn new(session_id: impl Into<String>) -> Self {
144        Self {
145            session_id: session_id.into(),
146            state: Disconnected,
147        }
148    }
149
150    /// Transitions to the Connecting state (initiator side).
151    #[must_use]
152    pub fn connect(self) -> Session<Connecting> {
153        self.transition(Connecting)
154    }
155
156    /// Transitions to the Connecting state after accepting an inbound
157    /// TCP connection (acceptor side).
158    #[must_use]
159    pub fn accept(self) -> Session<Connecting> {
160        self.transition(Connecting)
161    }
162}
163
164impl Session<Connecting> {
165    /// Transitions to the LogonSent state after sending Logon (initiator
166    /// side), recording the send instant for the logon timeout.
167    #[must_use]
168    pub fn send_logon(self) -> Session<LogonSent> {
169        self.transition(LogonSent {
170            sent_at: Instant::now(),
171        })
172    }
173
174    /// Transitions to the LogonReceived state when a Logon arrives from
175    /// the counterparty (acceptor side), recording the arrival instant.
176    #[must_use]
177    pub fn on_logon_received(self) -> Session<LogonReceived> {
178        self.transition(LogonReceived {
179            received_at: Instant::now(),
180        })
181    }
182
183    /// Transitions back to Disconnected on connection failure.
184    #[must_use]
185    pub fn disconnect(self) -> Session<Disconnected> {
186        self.transition(Disconnected)
187    }
188}
189
190impl Session<LogonSent> {
191    /// Returns when the Logon was sent, for the `logon_timeout`.
192    #[must_use]
193    pub const fn sent_at(&self) -> Instant {
194        self.state.sent_at
195    }
196
197    /// Transitions to Active state on successful Logon acknowledgement.
198    #[must_use]
199    pub fn on_logon_ack(self) -> Session<Active> {
200        self.transition(Active)
201    }
202
203    /// Transitions to Disconnected on Logon rejection or timeout.
204    #[must_use]
205    pub fn on_logon_reject(self) -> Session<Disconnected> {
206        self.transition(Disconnected)
207    }
208}
209
210impl Session<LogonReceived> {
211    /// Returns when the counterparty's Logon arrived, for the authentication
212    /// deadline.
213    #[must_use]
214    pub const fn received_at(&self) -> Instant {
215        self.state.received_at
216    }
217
218    /// Transitions to Active after successful authentication, once the
219    /// Logon acknowledgement has been sent back to the counterparty.
220    #[must_use]
221    pub fn accept_logon(self) -> Session<Active> {
222        self.transition(Active)
223    }
224
225    /// Transitions to Disconnected when authentication fails and the
226    /// Logon is rejected (Logout/Reject sent, connection dropped).
227    #[must_use]
228    pub fn reject_logon(self) -> Session<Disconnected> {
229        self.transition(Disconnected)
230    }
231
232    /// Transitions to Disconnected when authentication does not complete
233    /// within the allowed time.
234    #[must_use]
235    pub fn on_timeout(self) -> Session<Disconnected> {
236        self.transition(Disconnected)
237    }
238}
239
240impl Session<Active> {
241    /// Transitions to Resending state when a gap is detected, carrying the
242    /// range the resend covers.
243    ///
244    /// # Arguments
245    /// * `begin_seq` - `BeginSeqNo` (7) of the gap
246    /// * `end_seq` - `EndSeqNo` (16) of the gap; `0` means "through the last
247    ///   message sent", the FIX convention
248    #[must_use]
249    pub fn start_resend(self, begin_seq: u64, end_seq: u64) -> Session<Resending> {
250        self.transition(Resending { begin_seq, end_seq })
251    }
252
253    /// Transitions to LogoutPending state, recording the send instant for the
254    /// logout timeout.
255    #[must_use]
256    pub fn initiate_logout(self) -> Session<LogoutPending> {
257        self.transition(LogoutPending {
258            sent_at: Instant::now(),
259        })
260    }
261
262    /// Transitions to Disconnected on unexpected disconnect.
263    #[must_use]
264    pub fn disconnect(self) -> Session<Disconnected> {
265        self.transition(Disconnected)
266    }
267}
268
269impl Session<Resending> {
270    /// Returns `BeginSeqNo` (7) of the range being resent.
271    #[must_use]
272    pub const fn begin_seq(&self) -> u64 {
273        self.state.begin_seq
274    }
275
276    /// Returns `EndSeqNo` (16) of the range being resent; `0` means "through
277    /// the last message sent".
278    #[must_use]
279    pub const fn end_seq(&self) -> u64 {
280        self.state.end_seq
281    }
282
283    /// Transitions back to Active when resend is complete.
284    #[must_use]
285    pub fn resend_complete(self) -> Session<Active> {
286        self.transition(Active)
287    }
288
289    /// Transitions to Disconnected on error.
290    #[must_use]
291    pub fn disconnect(self) -> Session<Disconnected> {
292        self.transition(Disconnected)
293    }
294}
295
296impl Session<LogoutPending> {
297    /// Returns when the Logout was sent, for the `logout_timeout`.
298    #[must_use]
299    pub const fn sent_at(&self) -> Instant {
300        self.state.sent_at
301    }
302
303    /// Transitions to Disconnected on Logout acknowledgement or timeout.
304    #[must_use]
305    pub fn on_logout_ack(self) -> Session<Disconnected> {
306        self.transition(Disconnected)
307    }
308
309    /// Transitions to Disconnected on timeout.
310    #[must_use]
311    pub fn on_timeout(self) -> Session<Disconnected> {
312        self.transition(Disconnected)
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn test_session_state_transitions() {
322        let session = Session::<Disconnected>::new("TEST");
323        assert_eq!(session.session_id(), "TEST");
324
325        let session = session.connect();
326        let session = session.send_logon();
327        let session = session.on_logon_ack();
328
329        // Now in Active state
330        let session = session.initiate_logout();
331        let _session = session.on_logout_ack();
332    }
333
334    #[test]
335    fn test_acceptor_flow() {
336        let session = Session::<Disconnected>::new("ACCEPTOR");
337        let session = session.accept();
338        let session = session.on_logon_received();
339        let session = session.accept_logon();
340
341        // Now in Active state
342        let session = session.initiate_logout();
343        let _session = session.on_logout_ack();
344    }
345
346    #[test]
347    fn test_acceptor_reject_flow() {
348        let session = Session::<Disconnected>::new("ACCEPTOR");
349        let session = session.accept();
350        let session = session.on_logon_received();
351        let _session = session.reject_logon();
352    }
353
354    #[test]
355    fn test_acceptor_timeout_flow() {
356        let session = Session::<Disconnected>::new("ACCEPTOR");
357        let session = session.accept();
358        let session = session.on_logon_received();
359        let _session = session.on_timeout();
360    }
361
362    #[test]
363    fn test_resend_flow() {
364        let session = Session::<Disconnected>::new("TEST");
365        let session = session.connect();
366        let session = session.send_logon();
367        let session = session.on_logon_ack();
368
369        let session = session.start_resend(1, 5);
370        let _session = session.resend_complete();
371    }
372
373    // --- States carry their data --------------------------------------------
374
375    #[test]
376    fn test_send_logon_records_the_send_instant() {
377        let before = Instant::now();
378        let session = Session::<Disconnected>::new("TEST").connect().send_logon();
379        let after = Instant::now();
380
381        assert!(session.sent_at() >= before);
382        assert!(session.sent_at() <= after);
383        assert_eq!(session.state().sent_at, session.sent_at());
384    }
385
386    #[test]
387    fn test_on_logon_received_records_the_arrival_instant() {
388        let before = Instant::now();
389        let session = Session::<Disconnected>::new("ACCEPTOR")
390            .accept()
391            .on_logon_received();
392        let after = Instant::now();
393
394        assert!(session.received_at() >= before);
395        assert!(session.received_at() <= after);
396    }
397
398    #[test]
399    fn test_initiate_logout_records_the_send_instant() {
400        let before = Instant::now();
401        let session = Session::<Disconnected>::new("TEST")
402            .connect()
403            .send_logon()
404            .on_logon_ack()
405            .initiate_logout();
406        let after = Instant::now();
407
408        assert!(session.sent_at() >= before);
409        assert!(session.sent_at() <= after);
410    }
411
412    #[test]
413    fn test_start_resend_keeps_the_requested_range() {
414        let session = Session::<Disconnected>::new("TEST")
415            .connect()
416            .send_logon()
417            .on_logon_ack()
418            .start_resend(7, 16);
419
420        assert_eq!(session.begin_seq(), 7);
421        assert_eq!(session.end_seq(), 16);
422        assert_eq!(session.state().begin_seq, 7);
423    }
424
425    #[test]
426    fn test_start_resend_keeps_the_open_ended_range() {
427        // EndSeqNo (16) = 0 is the FIX "through the last message" convention
428        // and must survive the transition unchanged.
429        let session = Session::<Disconnected>::new("TEST")
430            .connect()
431            .send_logon()
432            .on_logon_ack()
433            .start_resend(42, 0);
434
435        assert_eq!(session.begin_seq(), 42);
436        assert_eq!(session.end_seq(), 0);
437    }
438
439    #[test]
440    fn test_session_id_survives_every_transition() {
441        let session = Session::<Disconnected>::new("PERSISTENT")
442            .connect()
443            .send_logon()
444            .on_logon_ack();
445        assert_eq!(session.session_id(), "PERSISTENT");
446
447        let session = session.start_resend(1, 2).resend_complete();
448        assert_eq!(session.session_id(), "PERSISTENT");
449
450        let session = session.initiate_logout();
451        assert_eq!(session.session_id(), "PERSISTENT");
452
453        let session = session.on_logout_ack();
454        assert_eq!(session.session_id(), "PERSISTENT");
455    }
456
457    #[test]
458    fn test_stateless_states_stay_zero_sized() {
459        use std::mem::size_of;
460
461        assert_eq!(size_of::<Disconnected>(), 0);
462        assert_eq!(size_of::<Connecting>(), 0);
463        assert_eq!(size_of::<Active>(), 0);
464        assert_eq!(size_of::<Session<Active>>(), size_of::<String>());
465    }
466}