Skip to main content

nntp_proxy/session/
state.rs

1//! Session loop state management
2//!
3//! This module provides the `SessionLoopState` struct which encapsulates
4//! all mutable state needed during a session command loop.
5
6use crate::protocol::RequestKind;
7use crate::session::backend::BackendResponseOrder;
8use crate::session::multiline_framing::{OrderedClientWrites, ReadyDeferredReplies};
9use crate::types::{BackendToClientBytes, ClientToBackendBytes, TransferMetrics};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum StatefulReadMode {
13    Bidirectional,
14    DrainBackendReplies,
15}
16
17/// Session loop state for tracking bytes, auth, and metrics
18///
19/// This struct encapsulates all mutable state needed during a session loop,
20/// making it easy to pass around and test in isolation.
21///
22/// # Example
23/// ```ignore
24/// let state = SessionLoopState::new(auth_enabled)
25///     .with_initial_bytes(1000, 500);
26/// ```
27pub struct SessionLoopState {
28    /// Bytes sent from client to backend
29    pub client_to_backend: ClientToBackendBytes,
30    /// Bytes sent from backend to client
31    pub backend_to_client: BackendToClientBytes,
32    /// Last reported client-to-backend bytes (for incremental metrics)
33    pub last_reported_c2b: ClientToBackendBytes,
34    /// Last reported backend-to-client bytes (for incremental metrics)
35    pub last_reported_b2c: BackendToClientBytes,
36    /// Iteration counter for metrics flush timing
37    iteration_count: u32,
38    /// Username from AUTHINFO USER command (if any)
39    pub auth_username: Option<String>,
40    /// Whether to skip auth checking (optimization after first auth)
41    pub skip_auth_check: bool,
42    /// Forwarded backend replies and deferred local replies in client-visible order.
43    backend_replies: BackendResponseOrder,
44}
45
46impl Default for SessionLoopState {
47    fn default() -> Self {
48        Self::new(false)
49    }
50}
51
52impl SessionLoopState {
53    /// Create new session loop state
54    ///
55    /// # Arguments
56    /// * `auth_enabled` - If true, auth checking starts enabled; if false, it's skipped
57    #[must_use]
58    pub fn new(auth_enabled: bool) -> Self {
59        Self {
60            client_to_backend: ClientToBackendBytes::zero(),
61            backend_to_client: BackendToClientBytes::zero(),
62            last_reported_c2b: ClientToBackendBytes::zero(),
63            last_reported_b2c: BackendToClientBytes::zero(),
64            iteration_count: 0,
65            auth_username: None,
66            skip_auth_check: !auth_enabled,
67            backend_replies: BackendResponseOrder::default(),
68        }
69    }
70
71    /// Create session loop state with initial byte counts
72    ///
73    /// Used by hybrid mode when switching from per-command to stateful,
74    /// to carry forward the bytes already transferred.
75    #[must_use]
76    pub fn from_initial_bytes(
77        client_to_backend: u64,
78        backend_to_client: u64,
79        auth_enabled: bool,
80    ) -> Self {
81        Self::new(auth_enabled).with_initial_bytes(client_to_backend, backend_to_client)
82    }
83
84    /// Builder method: set initial byte counts
85    #[must_use]
86    pub const fn with_initial_bytes(mut self, c2b: u64, b2c: u64) -> Self {
87        self.client_to_backend = ClientToBackendBytes::new(c2b);
88        self.backend_to_client = BackendToClientBytes::new(b2c);
89        self.last_reported_c2b = self.client_to_backend;
90        self.last_reported_b2c = self.backend_to_client;
91        self
92    }
93
94    /// Check if metrics should be flushed and reset counter if so
95    ///
96    /// Returns `true` every `METRICS_FLUSH_INTERVAL` iterations.
97    #[inline]
98    pub const fn check_and_maybe_flush_metrics(&mut self) -> bool {
99        self.iteration_count += 1;
100        if self.iteration_count >= crate::constants::session::METRICS_FLUSH_INTERVAL {
101            self.iteration_count = 0;
102            true
103        } else {
104            false
105        }
106    }
107
108    /// Add bytes to client-to-backend counter
109    #[inline]
110    pub const fn add_client_to_backend(&mut self, bytes: usize) {
111        self.client_to_backend = self.client_to_backend.add(bytes);
112    }
113
114    /// Add bytes to backend-to-client counter
115    #[inline]
116    pub const fn add_backend_to_client(&mut self, bytes: u64) {
117        self.backend_to_client = self.backend_to_client.add_u64(bytes);
118    }
119
120    /// Flush accumulated byte deltas to the metrics collector.
121    ///
122    /// Reports the difference since the last flush and updates the last-reported watermarks.
123    /// Used by both the periodic in-loop flush and the final flush on disconnect.
124    pub fn flush_byte_deltas(
125        &mut self,
126        metrics: &crate::metrics::MetricsCollector,
127        backend_id: crate::types::BackendId,
128        username: Option<&str>,
129    ) {
130        let delta_c2b = self
131            .client_to_backend
132            .as_u64()
133            .saturating_sub(self.last_reported_c2b.as_u64());
134        let delta_b2c = self
135            .backend_to_client
136            .as_u64()
137            .saturating_sub(self.last_reported_b2c.as_u64());
138
139        if delta_c2b > 0 {
140            metrics.record_client_to_backend_bytes_for(backend_id, delta_c2b);
141            metrics.user_bytes_sent(username, delta_c2b);
142        }
143        if delta_b2c > 0 {
144            metrics.record_backend_to_client_bytes_for(backend_id, delta_b2c);
145            metrics.user_bytes_received(username, delta_b2c);
146        }
147
148        self.last_reported_c2b = self.client_to_backend;
149        self.last_reported_b2c = self.backend_to_client;
150    }
151
152    /// Convert to final transfer metrics
153    #[must_use]
154    pub fn into_metrics(self) -> TransferMetrics {
155        TransferMetrics {
156            client_to_backend: self.client_to_backend,
157            backend_to_client: self.backend_to_client,
158        }
159    }
160
161    /// Mark authentication as complete (skip future checks)
162    #[inline]
163    pub fn mark_authenticated(&mut self) {
164        self.skip_auth_check = true;
165    }
166
167    /// Update state based on auth handler result
168    ///
169    /// Returns the bytes written for convenience in chaining.
170    pub fn apply_auth_result(&mut self, result: &super::common::AuthHandlerResult) -> u64 {
171        let bytes = result.bytes_written();
172        self.add_backend_to_client(bytes);
173        if result.should_skip_further_checks() {
174            self.mark_authenticated();
175        }
176        bytes
177    }
178
179    /// Mark that a backend request was forwarded and its reply must be ordered first.
180    #[inline]
181    pub fn mark_backend_request_sent(&mut self, kind: RequestKind) {
182        self.backend_replies.push_request(kind);
183    }
184
185    /// Whether earlier forwarded backend replies are still ahead of any deferred local replies.
186    #[inline]
187    #[must_use]
188    pub fn has_pending_backend_replies(&self) -> bool {
189        self.backend_replies.has_pending_backend_replies()
190    }
191
192    /// Return client writes made ready by a backend read.
193    ///
194    /// This preserves client-visible response ordering even when one read
195    /// satisfies multiple pipelined backend replies.
196    #[must_use]
197    pub(in crate::session) fn client_writes_for_backend_read<'a>(
198        &mut self,
199        backend_read: &'a [u8],
200    ) -> OrderedClientWrites<'a> {
201        self.backend_replies
202            .client_writes_for_backend_read(backend_read)
203    }
204
205    /// Queue a local reply until earlier backend output has been sent.
206    pub fn push_deferred_reply(&mut self, reply: &'static [u8]) {
207        self.backend_replies.push_deferred_reply(reply);
208    }
209
210    /// Whether deferred local replies remain queued.
211    #[inline]
212    #[must_use]
213    pub fn has_deferred_replies(&self) -> bool {
214        self.backend_replies.has_deferred_replies()
215    }
216
217    /// Reading mode for the stateful loop.
218    #[must_use]
219    pub fn read_mode(&self) -> StatefulReadMode {
220        if self.backend_replies.should_drain_backend_replies() {
221            StatefulReadMode::DrainBackendReplies
222        } else {
223            StatefulReadMode::Bidirectional
224        }
225    }
226
227    /// Drain deferred local replies that are ready at the front of the ordered queue.
228    #[must_use]
229    pub fn take_ready_deferred_replies(&mut self) -> ReadyDeferredReplies {
230        self.backend_replies.take_ready_deferred_replies()
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::session::common::AuthHandlerResult;
238    use std::borrow::Cow;
239
240    fn drain_backend_bytes(state: &mut SessionLoopState, bytes: &[u8]) -> Vec<Vec<u8>> {
241        state
242            .client_writes_for_backend_read(bytes)
243            .into_iter()
244            .map(Cow::into_owned)
245            .collect()
246    }
247
248    #[test]
249    fn test_session_loop_state_new() {
250        let state = SessionLoopState::new(true);
251        assert_eq!(state.client_to_backend.as_u64(), 0);
252        assert_eq!(state.backend_to_client.as_u64(), 0);
253        assert!(!state.skip_auth_check); // Auth enabled = don't skip
254        assert!(state.auth_username.is_none());
255
256        let state2 = SessionLoopState::new(false);
257        assert!(state2.skip_auth_check); // Auth disabled = skip
258    }
259
260    #[test]
261    fn test_session_loop_state_default() {
262        let state = SessionLoopState::default();
263        assert_eq!(state.client_to_backend.as_u64(), 0);
264        assert!(state.skip_auth_check); // Default = auth disabled
265        assert!(!state.has_pending_backend_replies());
266    }
267
268    #[test]
269    fn test_session_loop_state_builder_pattern() {
270        let state = SessionLoopState::new(false).with_initial_bytes(1000, 500);
271
272        assert_eq!(state.client_to_backend.as_u64(), 1000);
273        assert_eq!(state.backend_to_client.as_u64(), 500);
274    }
275
276    #[test]
277    fn test_session_loop_state_from_initial_bytes() {
278        let state = SessionLoopState::from_initial_bytes(100, 200, true);
279        assert_eq!(state.client_to_backend.as_u64(), 100);
280        assert_eq!(state.backend_to_client.as_u64(), 200);
281        assert_eq!(state.last_reported_c2b.as_u64(), 100);
282        assert_eq!(state.last_reported_b2c.as_u64(), 200);
283        assert!(!state.skip_auth_check);
284    }
285
286    #[test]
287    fn test_session_loop_state_add_bytes() {
288        let mut state = SessionLoopState::new(false);
289
290        state.add_client_to_backend(100);
291        assert_eq!(state.client_to_backend.as_u64(), 100);
292
293        state.add_backend_to_client(200);
294        assert_eq!(state.backend_to_client.as_u64(), 200);
295
296        // Cumulative
297        state.add_client_to_backend(50);
298        state.add_backend_to_client(50);
299        assert_eq!(state.client_to_backend.as_u64(), 150);
300        assert_eq!(state.backend_to_client.as_u64(), 250);
301    }
302
303    #[test]
304    fn test_session_loop_state_mark_authenticated() {
305        let mut state = SessionLoopState::new(true);
306        assert!(!state.skip_auth_check);
307
308        state.mark_authenticated();
309        assert!(state.skip_auth_check);
310    }
311
312    #[test]
313    fn test_session_loop_state_deferred_replies() {
314        let mut state = SessionLoopState::new(false);
315
316        state.mark_backend_request_sent(RequestKind::Date);
317        assert!(state.has_pending_backend_replies());
318
319        state.push_deferred_reply(b"205 Goodbye\r\n");
320        assert!(state.take_ready_deferred_replies().is_empty());
321
322        let rendered = drain_backend_bytes(&mut state, b"111 20260505120000\r\n");
323        assert!(!state.has_pending_backend_replies());
324        assert_eq!(
325            rendered,
326            vec![
327                b"111 20260505120000\r\n".to_vec(),
328                b"205 Goodbye\r\n".to_vec()
329            ]
330        );
331    }
332
333    #[test]
334    fn test_session_loop_state_ready_deferred_replies_stay_inline() {
335        let mut state = SessionLoopState::new(false);
336
337        state.push_deferred_reply(b"205 Goodbye\r\n");
338        let replies = state.take_ready_deferred_replies();
339
340        assert!(
341            !replies.spilled(),
342            "ready deferred replies should not allocate in the common case"
343        );
344        assert_eq!(replies.as_slice(), [b"205 Goodbye\r\n".as_slice()]);
345    }
346
347    #[test]
348    fn test_session_loop_state_enters_backend_drain_mode_for_deferred_locals() {
349        let mut state = SessionLoopState::new(false);
350
351        assert_eq!(state.read_mode(), StatefulReadMode::Bidirectional);
352
353        state.mark_backend_request_sent(RequestKind::Help);
354        state.push_deferred_reply(b"101 Capability list:\r\n.\r\n");
355        assert_eq!(state.read_mode(), StatefulReadMode::DrainBackendReplies);
356
357        drain_backend_bytes(&mut state, b"100 Help follows\r\n.\r\n");
358        assert_eq!(state.read_mode(), StatefulReadMode::Bidirectional);
359    }
360
361    #[test]
362    fn test_pending_backend_replies_handle_empty_response_body() {
363        let mut state = SessionLoopState::new(false);
364
365        state.mark_backend_request_sent(RequestKind::Help);
366        drain_backend_bytes(&mut state, b"100 Help follows\r\n.\r\n");
367        assert!(
368            !state.has_pending_backend_replies(),
369            "empty response body replies should complete immediately"
370        );
371    }
372
373    #[test]
374    fn test_pending_backend_reply_tracking_cap_prevents_unbounded_growth() {
375        let mut state = SessionLoopState::new(false);
376        state.mark_backend_request_sent(RequestKind::Date);
377
378        drain_backend_bytes(
379            &mut state,
380            &vec![b'x'; crate::constants::buffer::COMMAND + 1],
381        );
382
383        assert!(
384            !state.has_pending_backend_replies(),
385            "oversized replies should stop pending bookkeeping instead of growing forever"
386        );
387    }
388
389    #[test]
390    fn test_deferred_local_reply_flushes_before_later_backend_reply() {
391        let mut state = SessionLoopState::new(false);
392        let deferred = b"101 Capability list:\r\n.\r\n";
393
394        state.mark_backend_request_sent(RequestKind::Date);
395        state.push_deferred_reply(deferred);
396        state.mark_backend_request_sent(RequestKind::Date);
397
398        let rendered = drain_backend_bytes(&mut state, b"111 20260505120000\r\n");
399        assert!(
400            state.has_pending_backend_replies(),
401            "later backend replies must remain pending after the first one completes"
402        );
403        assert_eq!(state.read_mode(), StatefulReadMode::Bidirectional);
404        assert_eq!(
405            rendered,
406            vec![b"111 20260505120000\r\n".to_vec(), deferred.to_vec()]
407        );
408
409        drain_backend_bytes(&mut state, b"111 20260505120001\r\n");
410        assert!(!state.has_pending_backend_replies());
411    }
412
413    #[test]
414    fn test_client_writes_for_backend_read_orders_replies_around_deferred_reply() {
415        let mut state = SessionLoopState::new(false);
416        let deferred = b"101 Capability list:\r\n.\r\n";
417
418        state.mark_backend_request_sent(RequestKind::Date);
419        state.push_deferred_reply(deferred);
420        state.mark_backend_request_sent(RequestKind::Date);
421
422        let rendered: Vec<Vec<u8>> = state
423            .client_writes_for_backend_read(b"111 20260505120000\r\n111 20260505120001\r\n")
424            .into_iter()
425            .map(Cow::into_owned)
426            .collect();
427
428        assert_eq!(
429            rendered,
430            vec![
431                b"111 20260505120000\r\n".to_vec(),
432                deferred.to_vec(),
433                b"111 20260505120001\r\n".to_vec(),
434            ]
435        );
436        assert!(!state.has_pending_backend_replies());
437    }
438
439    #[test]
440    fn test_client_writes_for_backend_read_orders_pipelined_body_replies() {
441        let mut state = SessionLoopState::new(false);
442        state.mark_backend_request_sent(RequestKind::Help);
443        state.mark_backend_request_sent(RequestKind::Help);
444
445        let rendered: Vec<Vec<u8>> = state
446            .client_writes_for_backend_read(
447                b"100 Help follows\r\nbody one\r\n.\r\n100 Help follows\r\nbody two\r\n.\r\n",
448            )
449            .into_iter()
450            .map(Cow::into_owned)
451            .collect();
452
453        assert_eq!(
454            rendered,
455            vec![
456                b"100 Help follows\r\nbody one\r\n.\r\n".to_vec(),
457                b"100 Help follows\r\nbody two\r\n.\r\n".to_vec(),
458            ]
459        );
460        assert!(!state.has_pending_backend_replies());
461    }
462
463    #[test]
464    fn test_session_loop_state_apply_auth_result() {
465        let mut state = SessionLoopState::new(true);
466        assert!(!state.skip_auth_check);
467        assert_eq!(state.backend_to_client.as_u64(), 0);
468
469        // Authenticated result should update bytes and skip flag
470        let result = AuthHandlerResult::Authenticated {
471            bytes_written: 100,
472            skip_further_checks: true,
473        };
474        let bytes = state.apply_auth_result(&result);
475
476        assert_eq!(bytes, 100);
477        assert_eq!(state.backend_to_client.as_u64(), 100);
478        assert!(state.skip_auth_check);
479    }
480
481    #[test]
482    fn test_session_loop_state_apply_auth_result_not_authenticated() {
483        let mut state = SessionLoopState::new(true);
484
485        let result = AuthHandlerResult::NotAuthenticated { bytes_written: 50 };
486        state.apply_auth_result(&result);
487
488        assert_eq!(state.backend_to_client.as_u64(), 50);
489        assert!(!state.skip_auth_check); // Still need to check
490    }
491
492    #[test]
493    fn test_session_loop_state_into_metrics() {
494        let state = SessionLoopState::new(false).with_initial_bytes(1000, 2000);
495
496        let metrics = state.into_metrics();
497        assert_eq!(metrics.client_to_backend.as_u64(), 1000);
498        assert_eq!(metrics.backend_to_client.as_u64(), 2000);
499    }
500
501    #[test]
502    fn test_session_loop_state_metrics_flush_interval() {
503        use crate::constants::session::METRICS_FLUSH_INTERVAL;
504
505        let mut state = SessionLoopState::new(false);
506
507        // Should return false until we hit the interval
508        for _ in 0..(METRICS_FLUSH_INTERVAL - 1) {
509            assert!(!state.check_and_maybe_flush_metrics());
510        }
511
512        // Should return true at the interval
513        assert!(state.check_and_maybe_flush_metrics());
514
515        // Counter should reset, so next METRICS_FLUSH_INTERVAL-1 should be false
516        assert!(!state.check_and_maybe_flush_metrics());
517    }
518}