Skip to main content

sectorsync_core/
gateway.rs

1//! Low-level gateway session and client routing primitives.
2
3use std::collections::BTreeMap;
4
5use crate::command::{CommandEnvelope, CommandRejectReason};
6use crate::ids::{ClientId, StationId, Tick};
7
8/// Gateway/session table configuration.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub struct GatewayConfig {
11    /// Maximum tracked client sessions.
12    pub max_sessions: usize,
13    /// Number of ticks a disconnected session may reconnect with its current
14    /// generation.
15    pub reconnect_grace_ticks: u64,
16    /// Maximum admitted commands per client per tick.
17    pub max_commands_per_tick: usize,
18}
19
20impl Default for GatewayConfig {
21    fn default() -> Self {
22        Self {
23            max_sessions: 65_536,
24            reconnect_grace_ticks: 20 * 60,
25            max_commands_per_tick: 64,
26        }
27    }
28}
29
30/// Gateway route snapshot for a client.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct GatewayRoute {
33    /// Routed client.
34    pub client_id: ClientId,
35    /// Current target station for client commands.
36    pub station_id: StationId,
37    /// Session generation used by reconnect handshakes.
38    pub generation: u64,
39    /// Route epoch incremented every time a client changes station route.
40    pub route_epoch: u64,
41}
42
43/// Session connection state.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum GatewaySessionState {
46    /// Client is connected and may submit commands.
47    Connected,
48    /// Client disconnected and may reconnect until the grace window expires.
49    Disconnected {
50        /// Tick at which the disconnect was observed.
51        since: Tick,
52    },
53}
54
55/// Tracked client gateway session.
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub struct GatewaySession {
58    /// Client id.
59    pub client_id: ClientId,
60    /// Current target station.
61    pub station_id: StationId,
62    /// Tick at which this generation was first connected.
63    pub connected_at: Tick,
64    /// Last tick at which the client was observed.
65    pub last_seen: Tick,
66    /// Reconnect generation. External gateways may expose this as an opaque
67    /// token after adding their own authentication.
68    pub generation: u64,
69    /// Route epoch incremented on station route changes.
70    pub route_epoch: u64,
71    /// Connection state.
72    pub state: GatewaySessionState,
73    last_sequence: Option<u64>,
74    command_tick: Tick,
75    commands_this_tick: usize,
76}
77
78impl GatewaySession {
79    /// Returns a route snapshot.
80    pub const fn route(&self) -> GatewayRoute {
81        GatewayRoute {
82            client_id: self.client_id,
83            station_id: self.station_id,
84            generation: self.generation,
85            route_epoch: self.route_epoch,
86        }
87    }
88
89    /// Returns the latest accepted command sequence.
90    pub const fn last_sequence(&self) -> Option<u64> {
91        self.last_sequence
92    }
93
94    /// Returns command count admitted in the currently tracked tick.
95    pub const fn commands_this_tick(&self) -> usize {
96        self.commands_this_tick
97    }
98
99    /// Returns whether the session is connected.
100    pub const fn is_connected(&self) -> bool {
101        matches!(self.state, GatewaySessionState::Connected)
102    }
103}
104
105/// Result of connecting or reconnecting a client.
106#[derive(Clone, Copy, Debug, PartialEq, Eq)]
107pub enum GatewayConnectOutcome {
108    /// A new session entry was created.
109    Created,
110    /// An already connected session was refreshed.
111    AlreadyConnected,
112    /// A disconnected session reconnected within its grace window.
113    Reconnected {
114        /// Ticks between disconnect and reconnect.
115        disconnected_for: u64,
116    },
117    /// A stale disconnected session was replaced with a new generation.
118    ReplacedExpired {
119        /// Ticks between disconnect and replacement.
120        disconnected_for: u64,
121    },
122}
123
124/// Connection report.
125#[derive(Clone, Copy, Debug, PartialEq, Eq)]
126pub struct GatewayConnectReport {
127    /// Outcome.
128    pub outcome: GatewayConnectOutcome,
129    /// Current route.
130    pub route: GatewayRoute,
131}
132
133/// Result of admitting a command through gateway metadata checks.
134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub struct GatewayCommandAdmission {
136    /// Route that should receive the command.
137    pub route: GatewayRoute,
138    /// Accepted client-side command sequence.
139    pub sequence: u64,
140    /// Commands accepted for this client in the current tick.
141    pub commands_this_tick: usize,
142}
143
144/// Gateway session table statistics.
145#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
146pub struct GatewayStats {
147    /// Sessions created.
148    pub sessions_created: usize,
149    /// Successful reconnects.
150    pub sessions_reconnected: usize,
151    /// Disconnected sessions expired or replaced after grace.
152    pub sessions_expired: usize,
153    /// Route changes.
154    pub routes_changed: usize,
155    /// Commands admitted.
156    pub commands_admitted: usize,
157    /// Commands rejected as replay/stale.
158    pub commands_rejected_replay: usize,
159    /// Commands rejected by per-client rate limit.
160    pub commands_rejected_rate_limit: usize,
161}
162
163/// Gateway/session error.
164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
165pub enum GatewayError {
166    /// Session table is full.
167    CapacityFull {
168        /// Configured maximum session count.
169        capacity: usize,
170    },
171    /// Session does not exist.
172    MissingSession(ClientId),
173    /// Session is disconnected.
174    SessionDisconnected {
175        /// Client id.
176        client_id: ClientId,
177        /// Disconnect tick.
178        since: Tick,
179    },
180    /// Session reconnect generation does not match.
181    BadGeneration {
182        /// Expected generation.
183        expected: u64,
184        /// Provided generation.
185        actual: u64,
186    },
187    /// Command sequence was stale or replayed.
188    ReplayOrStale {
189        /// Latest accepted sequence, if any.
190        last_sequence: Option<u64>,
191        /// Submitted sequence.
192        sequence: u64,
193    },
194    /// Client exceeded the per-tick command admission limit.
195    RateLimited {
196        /// Configured limit.
197        limit: usize,
198        /// Attempted count in the tick.
199        attempted: usize,
200    },
201}
202
203impl GatewayError {
204    /// Maps gateway metadata errors into generic command reject reasons.
205    pub const fn command_reject_reason(&self) -> Option<CommandRejectReason> {
206        match self {
207            Self::ReplayOrStale { .. } => Some(CommandRejectReason::ReplayOrStale),
208            Self::RateLimited { .. } => Some(CommandRejectReason::RateLimited),
209            Self::MissingSession(_)
210            | Self::SessionDisconnected { .. }
211            | Self::BadGeneration { .. }
212            | Self::CapacityFull { .. } => None,
213        }
214    }
215}
216
217impl core::fmt::Display for GatewayError {
218    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
219        match self {
220            Self::CapacityFull { capacity } => {
221                write!(f, "gateway session table is full at capacity {capacity}")
222            }
223            Self::MissingSession(client_id) => {
224                write!(
225                    f,
226                    "gateway session for client {} is missing",
227                    client_id.get()
228                )
229            }
230            Self::SessionDisconnected { client_id, since } => write!(
231                f,
232                "gateway session for client {} disconnected at tick {}",
233                client_id.get(),
234                since.get()
235            ),
236            Self::BadGeneration { expected, actual } => write!(
237                f,
238                "gateway reconnect generation mismatch: expected {expected}, actual {actual}"
239            ),
240            Self::ReplayOrStale {
241                last_sequence,
242                sequence,
243            } => write!(
244                f,
245                "gateway command sequence {sequence} is not newer than {last_sequence:?}"
246            ),
247            Self::RateLimited { limit, attempted } => write!(
248                f,
249                "gateway command rate limited: limit {limit}, attempted {attempted}"
250            ),
251        }
252    }
253}
254
255impl std::error::Error for GatewayError {}
256
257/// Bounded in-memory gateway session and route table.
258#[derive(Clone, Debug)]
259pub struct GatewaySessionTable {
260    config: GatewayConfig,
261    sessions: BTreeMap<ClientId, GatewaySession>,
262    stats: GatewayStats,
263}
264
265impl GatewaySessionTable {
266    /// Creates an empty gateway session table.
267    pub fn new(config: GatewayConfig) -> Self {
268        Self {
269            config,
270            sessions: BTreeMap::new(),
271            stats: GatewayStats::default(),
272        }
273    }
274
275    /// Returns configuration.
276    pub const fn config(&self) -> GatewayConfig {
277        self.config
278    }
279
280    /// Returns statistics.
281    pub const fn stats(&self) -> GatewayStats {
282        self.stats
283    }
284
285    /// Returns tracked session count.
286    pub fn len(&self) -> usize {
287        self.sessions.len()
288    }
289
290    /// Returns whether no sessions are tracked.
291    pub fn is_empty(&self) -> bool {
292        self.sessions.is_empty()
293    }
294
295    /// Returns a session by client id.
296    pub fn session(&self, client_id: ClientId) -> Option<&GatewaySession> {
297        self.sessions.get(&client_id)
298    }
299
300    /// Returns a connected route by client id.
301    pub fn route(&self, client_id: ClientId) -> Result<GatewayRoute, GatewayError> {
302        let session = self
303            .sessions
304            .get(&client_id)
305            .ok_or(GatewayError::MissingSession(client_id))?;
306        match session.state {
307            GatewaySessionState::Connected => Ok(session.route()),
308            GatewaySessionState::Disconnected { since } => {
309                Err(GatewayError::SessionDisconnected { client_id, since })
310            }
311        }
312    }
313
314    /// Connects a client, reconnecting within grace or replacing stale sessions.
315    pub fn connect(
316        &mut self,
317        client_id: ClientId,
318        station_id: StationId,
319        now: Tick,
320    ) -> Result<GatewayConnectReport, GatewayError> {
321        if self.sessions.contains_key(&client_id) {
322            return Ok(self.connect_existing(client_id, station_id, now));
323        }
324
325        if self.sessions.len() >= self.config.max_sessions {
326            return Err(GatewayError::CapacityFull {
327                capacity: self.config.max_sessions,
328            });
329        }
330
331        let session = GatewaySession {
332            client_id,
333            station_id,
334            connected_at: now,
335            last_seen: now,
336            generation: 1,
337            route_epoch: 1,
338            state: GatewaySessionState::Connected,
339            last_sequence: None,
340            command_tick: now,
341            commands_this_tick: 0,
342        };
343        let route = session.route();
344        self.sessions.insert(client_id, session);
345        self.stats.sessions_created = self.stats.sessions_created.saturating_add(1);
346        Ok(GatewayConnectReport {
347            outcome: GatewayConnectOutcome::Created,
348            route,
349        })
350    }
351
352    /// Reconnects a disconnected client by generation.
353    pub fn reconnect(
354        &mut self,
355        client_id: ClientId,
356        generation: u64,
357        now: Tick,
358    ) -> Result<GatewayConnectReport, GatewayError> {
359        let session = self
360            .sessions
361            .get_mut(&client_id)
362            .ok_or(GatewayError::MissingSession(client_id))?;
363        if session.generation != generation {
364            return Err(GatewayError::BadGeneration {
365                expected: session.generation,
366                actual: generation,
367            });
368        }
369
370        match session.state {
371            GatewaySessionState::Connected => {
372                session.last_seen = now;
373                Ok(GatewayConnectReport {
374                    outcome: GatewayConnectOutcome::AlreadyConnected,
375                    route: session.route(),
376                })
377            }
378            GatewaySessionState::Disconnected { since } => {
379                let disconnected_for = now.get().saturating_sub(since.get());
380                if disconnected_for > self.config.reconnect_grace_ticks {
381                    session.connected_at = now;
382                    session.last_seen = now;
383                    session.generation = session.generation.saturating_add(1);
384                    session.route_epoch = session.route_epoch.saturating_add(1);
385                    session.state = GatewaySessionState::Connected;
386                    session.last_sequence = None;
387                    session.command_tick = now;
388                    session.commands_this_tick = 0;
389                    self.stats.sessions_expired = self.stats.sessions_expired.saturating_add(1);
390                    Ok(GatewayConnectReport {
391                        outcome: GatewayConnectOutcome::ReplacedExpired { disconnected_for },
392                        route: session.route(),
393                    })
394                } else {
395                    session.last_seen = now;
396                    session.state = GatewaySessionState::Connected;
397                    self.stats.sessions_reconnected =
398                        self.stats.sessions_reconnected.saturating_add(1);
399                    Ok(GatewayConnectReport {
400                        outcome: GatewayConnectOutcome::Reconnected { disconnected_for },
401                        route: session.route(),
402                    })
403                }
404            }
405        }
406    }
407
408    /// Marks a connected session disconnected.
409    pub fn disconnect(&mut self, client_id: ClientId, now: Tick) -> Result<(), GatewayError> {
410        let session = self
411            .sessions
412            .get_mut(&client_id)
413            .ok_or(GatewayError::MissingSession(client_id))?;
414        session.last_seen = now;
415        session.state = GatewaySessionState::Disconnected { since: now };
416        Ok(())
417    }
418
419    /// Removes disconnected sessions whose grace window has expired.
420    pub fn expire_disconnected(&mut self, now: Tick) -> usize {
421        let grace = self.config.reconnect_grace_ticks;
422        let expired = self
423            .sessions
424            .iter()
425            .filter_map(|(client_id, session)| match session.state {
426                GatewaySessionState::Connected => None,
427                GatewaySessionState::Disconnected { since } => {
428                    (now.get().saturating_sub(since.get()) > grace).then_some(*client_id)
429                }
430            })
431            .collect::<Vec<_>>();
432
433        for client_id in &expired {
434            self.sessions.remove(client_id);
435        }
436        self.stats.sessions_expired = self.stats.sessions_expired.saturating_add(expired.len());
437        expired.len()
438    }
439
440    /// Changes the target station for a connected client.
441    pub fn reroute(
442        &mut self,
443        client_id: ClientId,
444        station_id: StationId,
445        now: Tick,
446    ) -> Result<GatewayRoute, GatewayError> {
447        let session = self
448            .sessions
449            .get_mut(&client_id)
450            .ok_or(GatewayError::MissingSession(client_id))?;
451        match session.state {
452            GatewaySessionState::Connected => {
453                session.last_seen = now;
454                if session.station_id != station_id {
455                    session.station_id = station_id;
456                    session.route_epoch = session.route_epoch.saturating_add(1);
457                    self.stats.routes_changed = self.stats.routes_changed.saturating_add(1);
458                }
459                Ok(session.route())
460            }
461            GatewaySessionState::Disconnected { since } => {
462                Err(GatewayError::SessionDisconnected { client_id, since })
463            }
464        }
465    }
466
467    /// Applies session metadata checks to a command and returns the route that
468    /// should receive it.
469    pub fn admit_command(
470        &mut self,
471        command: &CommandEnvelope,
472    ) -> Result<GatewayCommandAdmission, GatewayError> {
473        self.admit_sequence(command.client_id, command.sequence, command.received_at)
474    }
475
476    /// Applies session metadata checks to a client command sequence.
477    pub fn admit_sequence(
478        &mut self,
479        client_id: ClientId,
480        sequence: u64,
481        now: Tick,
482    ) -> Result<GatewayCommandAdmission, GatewayError> {
483        let session = self
484            .sessions
485            .get_mut(&client_id)
486            .ok_or(GatewayError::MissingSession(client_id))?;
487        match session.state {
488            GatewaySessionState::Connected => {}
489            GatewaySessionState::Disconnected { since } => {
490                return Err(GatewayError::SessionDisconnected { client_id, since });
491            }
492        }
493
494        if session
495            .last_sequence
496            .is_some_and(|last_sequence| sequence <= last_sequence)
497        {
498            self.stats.commands_rejected_replay =
499                self.stats.commands_rejected_replay.saturating_add(1);
500            return Err(GatewayError::ReplayOrStale {
501                last_sequence: session.last_sequence,
502                sequence,
503            });
504        }
505
506        if session.command_tick != now {
507            session.command_tick = now;
508            session.commands_this_tick = 0;
509        }
510        let attempted = session.commands_this_tick.saturating_add(1);
511        if attempted > self.config.max_commands_per_tick {
512            self.stats.commands_rejected_rate_limit =
513                self.stats.commands_rejected_rate_limit.saturating_add(1);
514            return Err(GatewayError::RateLimited {
515                limit: self.config.max_commands_per_tick,
516                attempted,
517            });
518        }
519
520        session.commands_this_tick = attempted;
521        session.last_sequence = Some(sequence);
522        session.last_seen = now;
523        self.stats.commands_admitted = self.stats.commands_admitted.saturating_add(1);
524        Ok(GatewayCommandAdmission {
525            route: session.route(),
526            sequence,
527            commands_this_tick: attempted,
528        })
529    }
530
531    fn connect_existing(
532        &mut self,
533        client_id: ClientId,
534        station_id: StationId,
535        now: Tick,
536    ) -> GatewayConnectReport {
537        let session = self
538            .sessions
539            .get_mut(&client_id)
540            .expect("session existence was checked");
541        match session.state {
542            GatewaySessionState::Connected => {
543                session.last_seen = now;
544                if session.station_id != station_id {
545                    session.station_id = station_id;
546                    session.route_epoch = session.route_epoch.saturating_add(1);
547                    self.stats.routes_changed = self.stats.routes_changed.saturating_add(1);
548                }
549                GatewayConnectReport {
550                    outcome: GatewayConnectOutcome::AlreadyConnected,
551                    route: session.route(),
552                }
553            }
554            GatewaySessionState::Disconnected { since } => {
555                let disconnected_for = now.get().saturating_sub(since.get());
556                if disconnected_for > self.config.reconnect_grace_ticks {
557                    session.station_id = station_id;
558                    session.connected_at = now;
559                    session.last_seen = now;
560                    session.generation = session.generation.saturating_add(1);
561                    session.route_epoch = session.route_epoch.saturating_add(1);
562                    session.state = GatewaySessionState::Connected;
563                    session.last_sequence = None;
564                    session.command_tick = now;
565                    session.commands_this_tick = 0;
566                    self.stats.sessions_expired = self.stats.sessions_expired.saturating_add(1);
567                    GatewayConnectReport {
568                        outcome: GatewayConnectOutcome::ReplacedExpired { disconnected_for },
569                        route: session.route(),
570                    }
571                } else {
572                    session.station_id = station_id;
573                    session.last_seen = now;
574                    session.state = GatewaySessionState::Connected;
575                    self.stats.sessions_reconnected =
576                        self.stats.sessions_reconnected.saturating_add(1);
577                    GatewayConnectReport {
578                        outcome: GatewayConnectOutcome::Reconnected { disconnected_for },
579                        route: session.route(),
580                    }
581                }
582            }
583        }
584    }
585}
586
587impl Default for GatewaySessionTable {
588    fn default() -> Self {
589        Self::new(GatewayConfig::default())
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596    use crate::command::{CommandEnvelope, CommandPriority};
597    use crate::ids::{CommandId, EntityId};
598
599    fn command(client_id: ClientId, sequence: u64, tick: u64) -> CommandEnvelope {
600        CommandEnvelope {
601            id: CommandId::new(sequence),
602            client_id,
603            entity_id: EntityId::new(10),
604            sequence,
605            received_at: Tick::new(tick),
606            kind: 1,
607            priority: CommandPriority::Normal,
608            payload: Vec::new(),
609        }
610    }
611
612    #[test]
613    fn connects_routes_and_reroutes_sessions() {
614        let client_id = ClientId::new(7);
615        let mut table = GatewaySessionTable::new(GatewayConfig {
616            max_sessions: 4,
617            reconnect_grace_ticks: 3,
618            max_commands_per_tick: 4,
619        });
620
621        let connected = table
622            .connect(client_id, StationId::new(1), Tick::new(10))
623            .expect("connect should work");
624        assert_eq!(connected.outcome, GatewayConnectOutcome::Created);
625        assert_eq!(connected.route.station_id, StationId::new(1));
626        assert_eq!(connected.route.generation, 1);
627        assert_eq!(connected.route.route_epoch, 1);
628
629        let route = table
630            .reroute(client_id, StationId::new(2), Tick::new(11))
631            .expect("reroute should work");
632        assert_eq!(route.station_id, StationId::new(2));
633        assert_eq!(route.route_epoch, 2);
634        assert_eq!(table.stats().routes_changed, 1);
635        assert_eq!(
636            table
637                .route(client_id)
638                .expect("route should exist")
639                .station_id,
640            StationId::new(2)
641        );
642    }
643
644    #[test]
645    fn reconnects_with_generation_and_expires_disconnected_sessions() {
646        let client_id = ClientId::new(7);
647        let mut table = GatewaySessionTable::new(GatewayConfig {
648            max_sessions: 4,
649            reconnect_grace_ticks: 3,
650            max_commands_per_tick: 4,
651        });
652        let connected = table
653            .connect(client_id, StationId::new(1), Tick::new(10))
654            .expect("connect should work");
655        table
656            .disconnect(client_id, Tick::new(12))
657            .expect("disconnect should work");
658        assert!(matches!(
659            table.route(client_id),
660            Err(GatewayError::SessionDisconnected { .. })
661        ));
662
663        let bad = table
664            .reconnect(client_id, connected.route.generation + 1, Tick::new(13))
665            .expect_err("bad generation should fail");
666        assert_eq!(
667            bad,
668            GatewayError::BadGeneration {
669                expected: 1,
670                actual: 2
671            }
672        );
673
674        let reconnected = table
675            .reconnect(client_id, connected.route.generation, Tick::new(14))
676            .expect("reconnect should work");
677        assert_eq!(
678            reconnected.outcome,
679            GatewayConnectOutcome::Reconnected {
680                disconnected_for: 2
681            }
682        );
683        assert_eq!(reconnected.route.generation, 1);
684
685        table
686            .disconnect(client_id, Tick::new(15))
687            .expect("disconnect should work");
688        assert_eq!(table.expire_disconnected(Tick::new(19)), 1);
689        assert_eq!(table.len(), 0);
690        assert_eq!(table.stats().sessions_expired, 1);
691    }
692
693    #[test]
694    fn connect_replaces_stale_disconnected_session() {
695        let client_id = ClientId::new(7);
696        let mut table = GatewaySessionTable::new(GatewayConfig {
697            max_sessions: 4,
698            reconnect_grace_ticks: 3,
699            max_commands_per_tick: 4,
700        });
701        let connected = table
702            .connect(client_id, StationId::new(1), Tick::new(10))
703            .expect("connect should work");
704        table
705            .admit_sequence(client_id, 10, Tick::new(11))
706            .expect("first command should admit");
707        table
708            .disconnect(client_id, Tick::new(12))
709            .expect("disconnect should work");
710
711        let replaced = table
712            .connect(client_id, StationId::new(2), Tick::new(20))
713            .expect("stale reconnect should replace generation");
714        assert_eq!(
715            replaced.outcome,
716            GatewayConnectOutcome::ReplacedExpired {
717                disconnected_for: 8
718            }
719        );
720        assert_eq!(replaced.route.generation, connected.route.generation + 1);
721        assert_eq!(replaced.route.station_id, StationId::new(2));
722        assert_eq!(
723            table
724                .admit_sequence(client_id, 1, Tick::new(21))
725                .expect("new generation should reset sequence")
726                .sequence,
727            1
728        );
729    }
730
731    #[test]
732    fn command_admission_rejects_replay_and_rate_limit() {
733        let client_id = ClientId::new(7);
734        let mut table = GatewaySessionTable::new(GatewayConfig {
735            max_sessions: 4,
736            reconnect_grace_ticks: 3,
737            max_commands_per_tick: 2,
738        });
739        table
740            .connect(client_id, StationId::new(1), Tick::new(10))
741            .expect("connect should work");
742
743        let first = table
744            .admit_command(&command(client_id, 1, 10))
745            .expect("first command should admit");
746        assert_eq!(first.commands_this_tick, 1);
747        assert_eq!(first.route.station_id, StationId::new(1));
748
749        let replay = table
750            .admit_command(&command(client_id, 1, 10))
751            .expect_err("same sequence should reject");
752        assert_eq!(
753            replay,
754            GatewayError::ReplayOrStale {
755                last_sequence: Some(1),
756                sequence: 1
757            }
758        );
759        assert_eq!(
760            replay.command_reject_reason(),
761            Some(CommandRejectReason::ReplayOrStale)
762        );
763
764        table
765            .admit_command(&command(client_id, 2, 10))
766            .expect("second command should admit");
767        let limited = table
768            .admit_command(&command(client_id, 3, 10))
769            .expect_err("third same-tick command should rate limit");
770        assert_eq!(
771            limited,
772            GatewayError::RateLimited {
773                limit: 2,
774                attempted: 3
775            }
776        );
777        assert_eq!(
778            limited.command_reject_reason(),
779            Some(CommandRejectReason::RateLimited)
780        );
781
782        let next_tick = table
783            .admit_command(&command(client_id, 3, 11))
784            .expect("next tick should reset rate count");
785        assert_eq!(next_tick.commands_this_tick, 1);
786        assert_eq!(table.stats().commands_admitted, 3);
787        assert_eq!(table.stats().commands_rejected_replay, 1);
788        assert_eq!(table.stats().commands_rejected_rate_limit, 1);
789    }
790
791    #[test]
792    fn capacity_limit_is_enforced() {
793        let mut table = GatewaySessionTable::new(GatewayConfig {
794            max_sessions: 1,
795            reconnect_grace_ticks: 3,
796            max_commands_per_tick: 4,
797        });
798        table
799            .connect(ClientId::new(1), StationId::new(1), Tick::new(0))
800            .expect("first session should fit");
801        let error = table
802            .connect(ClientId::new(2), StationId::new(1), Tick::new(0))
803            .expect_err("second session should exceed capacity");
804        assert_eq!(error, GatewayError::CapacityFull { capacity: 1 });
805    }
806}