Skip to main content

ant_quic/
connection_lifecycle.rs

1use std::fmt;
2
3use crate::{ConnectionError, VarInt};
4
5/// Reserved application close-code range for ant-quic lifecycle signaling.
6///
7/// `0x4E5B00..=0x4E5BFF` encodes ASCII `N[` in the upper bytes.
8pub const ANT_QUIC_CLOSE_CODE_BASE: u32 = 0x4E5B00;
9const CLOSE_CODE_SUPERSEDED: u32 = ANT_QUIC_CLOSE_CODE_BASE;
10const CLOSE_CODE_READER_EXIT: u32 = ANT_QUIC_CLOSE_CODE_BASE + 0x01;
11const CLOSE_CODE_PEER_SHUTDOWN: u32 = ANT_QUIC_CLOSE_CODE_BASE + 0x02;
12const CLOSE_CODE_BANNED: u32 = ANT_QUIC_CLOSE_CODE_BASE + 0x03;
13const CLOSE_CODE_LIFECYCLE_CLEANUP: u32 = ANT_QUIC_CLOSE_CODE_BASE + 0x04;
14const CLOSE_CODE_LIVENESS_TIMEOUT: u32 = ANT_QUIC_CLOSE_CODE_BASE + 0x05;
15
16/// ant-quic lifecycle-aware connection close reasons.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum ConnectionCloseReason {
19    /// A newer connection superseded this one.
20    Superseded,
21    /// The reader task exited and the endpoint actively closed the connection.
22    ReaderExit,
23    /// The remote endpoint is shutting down.
24    PeerShutdown,
25    /// Trust or policy enforcement rejected the peer.
26    Banned,
27    /// Generic lifecycle cleanup.
28    LifecycleCleanup,
29    /// X0X-0062: the local endpoint detected the application data path is
30    /// dead (repeated `send_with_receive_ack` retries failed within a short
31    /// window) while the underlying QUIC connection still reports as `Live`.
32    /// Used to force-close half-dead connections so callers can re-dial.
33    LivenessTimeout,
34    /// The peer sent a non-lifecycle application close.
35    ApplicationClosed,
36    /// The peer or transport closed the connection without an application code.
37    ConnectionClosed,
38    /// The connection timed out.
39    TimedOut,
40    /// The peer reset the connection.
41    Reset,
42    /// A transport error closed the connection.
43    TransportError,
44    /// The local side closed the connection.
45    LocallyClosed,
46    /// Version or capability mismatch closed the connection.
47    VersionMismatch,
48    /// CID exhaustion closed the connection.
49    CidsExhausted,
50    /// Unknown or unmapped close reason.
51    Unknown,
52}
53
54impl ConnectionCloseReason {
55    /// Return the reserved QUIC application error code, if this reason has one.
56    pub fn app_error_code(self) -> Option<VarInt> {
57        let code = match self {
58            Self::Superseded => CLOSE_CODE_SUPERSEDED,
59            Self::ReaderExit => CLOSE_CODE_READER_EXIT,
60            Self::PeerShutdown => CLOSE_CODE_PEER_SHUTDOWN,
61            Self::Banned => CLOSE_CODE_BANNED,
62            Self::LifecycleCleanup => CLOSE_CODE_LIFECYCLE_CLEANUP,
63            Self::LivenessTimeout => CLOSE_CODE_LIVENESS_TIMEOUT,
64            Self::ApplicationClosed
65            | Self::ConnectionClosed
66            | Self::TimedOut
67            | Self::Reset
68            | Self::TransportError
69            | Self::LocallyClosed
70            | Self::VersionMismatch
71            | Self::CidsExhausted
72            | Self::Unknown => return None,
73        };
74        Some(VarInt::from_u32(code))
75    }
76
77    /// Human-readable identifier for logs and diagnostics.
78    pub fn as_str(self) -> &'static str {
79        match self {
80            Self::Superseded => "Superseded",
81            Self::ReaderExit => "ReaderExit",
82            Self::PeerShutdown => "PeerShutdown",
83            Self::Banned => "Banned",
84            Self::LifecycleCleanup => "LifecycleCleanup",
85            Self::LivenessTimeout => "LivenessTimeout",
86            Self::ApplicationClosed => "ApplicationClosed",
87            Self::ConnectionClosed => "ConnectionClosed",
88            Self::TimedOut => "TimedOut",
89            Self::Reset => "Reset",
90            Self::TransportError => "TransportError",
91            Self::LocallyClosed => "LocallyClosed",
92            Self::VersionMismatch => "VersionMismatch",
93            Self::CidsExhausted => "CidsExhausted",
94            Self::Unknown => "Unknown",
95        }
96    }
97
98    /// Static reason bytes used in CONNECTION_CLOSE frames.
99    pub fn reason_bytes(self) -> &'static [u8] {
100        self.as_str().as_bytes()
101    }
102
103    /// Map a QUIC application close code into a lifecycle reason.
104    pub fn from_app_error_code(code: VarInt) -> Option<Self> {
105        match code.into_inner() as u32 {
106            CLOSE_CODE_SUPERSEDED => Some(Self::Superseded),
107            CLOSE_CODE_READER_EXIT => Some(Self::ReaderExit),
108            CLOSE_CODE_PEER_SHUTDOWN => Some(Self::PeerShutdown),
109            CLOSE_CODE_BANNED => Some(Self::Banned),
110            CLOSE_CODE_LIFECYCLE_CLEANUP => Some(Self::LifecycleCleanup),
111            CLOSE_CODE_LIVENESS_TIMEOUT => Some(Self::LivenessTimeout),
112            _ => None,
113        }
114    }
115
116    /// Map a transport connection error into a lifecycle reason.
117    pub fn from_connection_error(error: &ConnectionError) -> Self {
118        match error {
119            ConnectionError::ApplicationClosed(frame) => {
120                Self::from_app_error_code(frame.error_code).unwrap_or(Self::ApplicationClosed)
121            }
122            ConnectionError::ConnectionClosed(_) => Self::ConnectionClosed,
123            ConnectionError::TransportError(_) => Self::TransportError,
124            ConnectionError::VersionMismatch => Self::VersionMismatch,
125            ConnectionError::Reset => Self::Reset,
126            ConnectionError::TimedOut => Self::TimedOut,
127            ConnectionError::LocallyClosed => Self::LocallyClosed,
128            ConnectionError::CidsExhausted => Self::CidsExhausted,
129        }
130    }
131}
132
133impl fmt::Display for ConnectionCloseReason {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        f.write_str(self.as_str())
136    }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub(crate) enum ConnectionLifecycleState {
141    Live,
142    Superseded {
143        replaced_by_generation: u64,
144    },
145    Closing {
146        reason: ConnectionCloseReason,
147    },
148    Closed {
149        reason: ConnectionCloseReason,
150        closed_at_unix_ms: u64,
151    },
152}
153
154impl ConnectionLifecycleState {
155    pub(crate) fn name(self) -> &'static str {
156        match self {
157            Self::Live => "Live",
158            Self::Superseded { .. } => "Superseded",
159            Self::Closing { .. } => "Closing",
160            Self::Closed { .. } => "Closed",
161        }
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use bytes::Bytes;
169
170    // ── ConnectionCloseReason tests ──
171
172    #[test]
173    fn reason_as_str_all_variants() {
174        let cases = [
175            (ConnectionCloseReason::Superseded, "Superseded"),
176            (ConnectionCloseReason::ReaderExit, "ReaderExit"),
177            (ConnectionCloseReason::PeerShutdown, "PeerShutdown"),
178            (ConnectionCloseReason::Banned, "Banned"),
179            (ConnectionCloseReason::LifecycleCleanup, "LifecycleCleanup"),
180            (ConnectionCloseReason::LivenessTimeout, "LivenessTimeout"),
181            (
182                ConnectionCloseReason::ApplicationClosed,
183                "ApplicationClosed",
184            ),
185            (ConnectionCloseReason::ConnectionClosed, "ConnectionClosed"),
186            (ConnectionCloseReason::TimedOut, "TimedOut"),
187            (ConnectionCloseReason::Reset, "Reset"),
188            (ConnectionCloseReason::TransportError, "TransportError"),
189            (ConnectionCloseReason::LocallyClosed, "LocallyClosed"),
190            (ConnectionCloseReason::VersionMismatch, "VersionMismatch"),
191            (ConnectionCloseReason::CidsExhausted, "CidsExhausted"),
192            (ConnectionCloseReason::Unknown, "Unknown"),
193        ];
194
195        for (reason, expected) in &cases {
196            assert_eq!(reason.as_str(), *expected);
197        }
198    }
199
200    #[test]
201    fn reason_display_matches_as_str() {
202        let reasons = [
203            ConnectionCloseReason::Superseded,
204            ConnectionCloseReason::ReaderExit,
205            ConnectionCloseReason::PeerShutdown,
206            ConnectionCloseReason::Banned,
207            ConnectionCloseReason::LifecycleCleanup,
208            ConnectionCloseReason::LivenessTimeout,
209            ConnectionCloseReason::ApplicationClosed,
210            ConnectionCloseReason::ConnectionClosed,
211            ConnectionCloseReason::TimedOut,
212            ConnectionCloseReason::Reset,
213            ConnectionCloseReason::TransportError,
214            ConnectionCloseReason::LocallyClosed,
215            ConnectionCloseReason::VersionMismatch,
216            ConnectionCloseReason::CidsExhausted,
217            ConnectionCloseReason::Unknown,
218        ];
219
220        for reason in &reasons {
221            assert_eq!(format!("{reason}"), reason.as_str());
222        }
223    }
224
225    #[test]
226    fn reason_reason_bytes_equals_as_str_bytes() {
227        let reasons = [
228            ConnectionCloseReason::Superseded,
229            ConnectionCloseReason::ReaderExit,
230            ConnectionCloseReason::LivenessTimeout,
231            ConnectionCloseReason::Unknown,
232        ];
233
234        for reason in &reasons {
235            assert_eq!(reason.reason_bytes(), reason.as_str().as_bytes());
236        }
237    }
238
239    #[test]
240    fn reason_equality() {
241        assert_eq!(
242            ConnectionCloseReason::Superseded,
243            ConnectionCloseReason::Superseded
244        );
245        assert_ne!(
246            ConnectionCloseReason::Superseded,
247            ConnectionCloseReason::ReaderExit
248        );
249    }
250
251    #[test]
252    fn reason_clone() {
253        let r = ConnectionCloseReason::Superseded;
254        assert_eq!(r.clone(), r);
255    }
256
257    // ── app_error_code tests ──
258
259    #[test]
260    fn lifecycle_reasons_have_app_error_codes() {
261        let has_code = [
262            ConnectionCloseReason::Superseded,
263            ConnectionCloseReason::ReaderExit,
264            ConnectionCloseReason::PeerShutdown,
265            ConnectionCloseReason::Banned,
266            ConnectionCloseReason::LifecycleCleanup,
267            ConnectionCloseReason::LivenessTimeout,
268        ];
269
270        for reason in &has_code {
271            assert!(
272                reason.app_error_code().is_some(),
273                "{reason:?} should have an app_error_code"
274            );
275        }
276    }
277
278    #[test]
279    fn non_lifecycle_reasons_have_no_app_error_code() {
280        let no_code = [
281            ConnectionCloseReason::ApplicationClosed,
282            ConnectionCloseReason::ConnectionClosed,
283            ConnectionCloseReason::TimedOut,
284            ConnectionCloseReason::Reset,
285            ConnectionCloseReason::TransportError,
286            ConnectionCloseReason::LocallyClosed,
287            ConnectionCloseReason::VersionMismatch,
288            ConnectionCloseReason::CidsExhausted,
289            ConnectionCloseReason::Unknown,
290        ];
291
292        for reason in &no_code {
293            assert!(
294                reason.app_error_code().is_none(),
295                "{reason:?} should NOT have an app_error_code"
296            );
297        }
298    }
299
300    #[test]
301    fn lifecycle_error_codes_start_at_base() {
302        let superseded_code = ConnectionCloseReason::Superseded
303            .app_error_code()
304            .unwrap()
305            .into_inner() as u32;
306        assert_eq!(superseded_code, ANT_QUIC_CLOSE_CODE_BASE);
307
308        let liveness_code = ConnectionCloseReason::LivenessTimeout
309            .app_error_code()
310            .unwrap()
311            .into_inner() as u32;
312        assert_eq!(liveness_code, ANT_QUIC_CLOSE_CODE_BASE + 5);
313    }
314
315    // ── from_app_error_code tests ──
316
317    #[test]
318    fn from_app_error_code_roundtrip() {
319        let lifecycle_reasons = [
320            ConnectionCloseReason::Superseded,
321            ConnectionCloseReason::ReaderExit,
322            ConnectionCloseReason::PeerShutdown,
323            ConnectionCloseReason::Banned,
324            ConnectionCloseReason::LifecycleCleanup,
325            ConnectionCloseReason::LivenessTimeout,
326        ];
327
328        for reason in &lifecycle_reasons {
329            let code = reason.app_error_code().unwrap();
330            let mapped = ConnectionCloseReason::from_app_error_code(code);
331            assert_eq!(mapped, Some(*reason));
332        }
333    }
334
335    #[test]
336    fn from_app_error_code_unknown_code() {
337        let code = VarInt::from_u32(0x1234);
338        let result = ConnectionCloseReason::from_app_error_code(code);
339        assert_eq!(result, None);
340    }
341
342    #[test]
343    fn from_app_error_code_zero() {
344        // Standard QUIC no-error should not map to a lifecycle reason
345        let code = VarInt::from_u32(0);
346        let result = ConnectionCloseReason::from_app_error_code(code);
347        assert_eq!(result, None);
348    }
349
350    // ── from_connection_error tests ──
351
352    #[test]
353    fn from_connection_error_application_closed_maps_to_lifecycle() {
354        let code = VarInt::from_u32(CLOSE_CODE_SUPERSEDED);
355        let app_close = crate::frame::ApplicationClose {
356            error_code: code,
357            reason: Bytes::new(),
358        };
359        let frame = crate::ConnectionError::ApplicationClosed(app_close);
360        let reason = ConnectionCloseReason::from_connection_error(&frame);
361        assert_eq!(reason, ConnectionCloseReason::Superseded);
362    }
363
364    #[test]
365    fn from_connection_error_application_closed_falls_back() {
366        let code = VarInt::from_u32(0x1234);
367        let app_close = crate::frame::ApplicationClose {
368            error_code: code,
369            reason: Bytes::new(),
370        };
371        let frame = crate::ConnectionError::ApplicationClosed(app_close);
372        let reason = ConnectionCloseReason::from_connection_error(&frame);
373        assert_eq!(reason, ConnectionCloseReason::ApplicationClosed);
374    }
375
376    // ── ConnectionLifecycleState tests ──
377
378    #[test]
379    fn lifecycle_state_name_live() {
380        assert_eq!(ConnectionLifecycleState::Live.name(), "Live");
381    }
382
383    #[test]
384    fn lifecycle_state_name_superseded() {
385        let state = ConnectionLifecycleState::Superseded {
386            replaced_by_generation: 42,
387        };
388        assert_eq!(state.name(), "Superseded");
389    }
390
391    #[test]
392    fn lifecycle_state_name_closing() {
393        let state = ConnectionLifecycleState::Closing {
394            reason: ConnectionCloseReason::PeerShutdown,
395        };
396        assert_eq!(state.name(), "Closing");
397    }
398
399    #[test]
400    fn lifecycle_state_name_closed() {
401        let state = ConnectionLifecycleState::Closed {
402            reason: ConnectionCloseReason::LivenessTimeout,
403            closed_at_unix_ms: 1000,
404        };
405        assert_eq!(state.name(), "Closed");
406    }
407
408    #[test]
409    fn lifecycle_state_equality() {
410        assert_eq!(
411            ConnectionLifecycleState::Live,
412            ConnectionLifecycleState::Live
413        );
414        assert_ne!(
415            ConnectionLifecycleState::Live,
416            ConnectionLifecycleState::Superseded {
417                replaced_by_generation: 1,
418            }
419        );
420    }
421
422    #[test]
423    fn lifecycle_state_debug() {
424        let state = ConnectionLifecycleState::Live;
425        let debug = format!("{state:?}");
426        assert!(debug.contains("Live"));
427    }
428}