Skip to main content

nntp_proxy/pool/
connection_guard.rs

1//! Connection guard for automatic cleanup of broken pooled connections
2//!
3//! This module provides utilities to automatically remove broken connections from
4//! the pool when I/O errors occur, preventing stale connections from being recycled.
5//!
6//! # CRITICAL: Connection Hold Time Guarantees
7//!
8//! All connection salvage operations MUST complete in <=1 second to prevent pool
9//! starvation and throughput collapse. This is enforced by:
10//! - Compile-time const assertions on timeout values
11//! - Type-level guarantees preventing timeout loops
12
13use deadpool::managed::Object;
14
15use crate::constants::pool::HEALTH_CHECK_TIMEOUT;
16use crate::pool::deadpool_connection::TcpManager;
17use crate::pool::provider::DeadpoolConnectionProvider;
18
19// ═══════════════════════════════════════════════════════════════════════════
20// COMPILE-TIME SAFEGUARDS: Connection Hold Time Limits
21// ═══════════════════════════════════════════════════════════════════════════
22
23/// Maximum time (milliseconds) any connection salvage operation can take
24///
25/// CRITICAL: If salvage takes longer than this, we risk pool starvation and
26/// throughput collapse. This constant is enforced by:
27/// - Const assertions below
28/// - Code review guidelines
29///
30/// Background: This bound must stay aligned with the configured DATE health-check timeout.
31pub const MAX_CONNECTION_SALVAGE_MS: u64 = 1_000;
32
33/// COMPILE-TIME ASSERTION: Prevent timeout loops from being added
34///
35/// This `const fn` exists purely to create a compile error if someone tries to add
36/// a timeout loop. Any loop with `MAX_ITERATIONS > 1` will fail this assertion.
37///
38/// Example that will NOT compile:
39/// ```compile_fail
40/// const MAX_DRAIN_ITERATIONS: usize = 50; // FAILS ASSERTION
41/// const DRAIN_TIMEOUT_MS: u64 = 200;
42/// const _: () = assert_no_timeout_loop(MAX_DRAIN_ITERATIONS, DRAIN_TIMEOUT_MS);
43/// ```
44#[allow(dead_code)] // Called from a const assertion; rustc still reports the helper as unused.
45const fn assert_no_timeout_loop(max_iterations: usize, timeout_per_iteration_ms: u64) {
46    // If you see this compile error, you're trying to add a timeout loop
47    // that could hold connections for too long. Use DATE health check instead.
48    assert!(
49        max_iterations == 1,
50        "Connection salvage MUST NOT use timeout loops (max_iterations must be 1)"
51    );
52    assert!(
53        timeout_per_iteration_ms <= MAX_CONNECTION_SALVAGE_MS,
54        "Single timeout must be <= MAX_CONNECTION_SALVAGE_MS"
55    );
56}
57
58// Apply assertion to salvage_with_health_check (implicit: it has no loop)
59const _SALVAGE_NO_LOOP: () = {
60    // salvage_with_health_check has exactly 1 operation (DATE check)
61    // Compare as u128 to avoid truncating cast (as_millis() returns u128; safe for any
62    // reasonable timeout value, but avoids the footgun entirely)
63    assert!(
64        HEALTH_CHECK_TIMEOUT.as_millis() <= MAX_CONNECTION_SALVAGE_MS as u128,
65        "HEALTH_CHECK_TIMEOUT must be <= MAX_CONNECTION_SALVAGE_MS"
66    );
67    // Assert exactly 1 operation (no loop)
68    assert_no_timeout_loop(1, MAX_CONNECTION_SALVAGE_MS);
69};
70
71/// RAII guard for pooled connections.
72///
73/// Automatically removes an unreleased connection from the pool on drop. Drop
74/// applies replacement cooldown because unreleased guards represent unknown or
75/// backend-error state. Use `retire_without_cooldown` for known client-side
76/// dirty sockets that should not throttle backend replacement.
77///
78/// Follows the same pattern as `CommandGuard` from `src/router/mod.rs`.
79pub struct ConnectionGuard {
80    conn: Option<Object<TcpManager>>,
81    provider: DeadpoolConnectionProvider,
82    released: bool,
83}
84
85impl ConnectionGuard {
86    /// Create a new guard (removes from pool on drop unless released).
87    pub const fn new(conn: Object<TcpManager>, provider: DeadpoolConnectionProvider) -> Self {
88        Self {
89            conn: Some(conn),
90            provider,
91            released: false,
92        }
93    }
94
95    /// Return connection to pool (healthy).
96    ///
97    /// Connection will be returned to the pool normally when the returned
98    /// `Object` is dropped. The guard is consumed — no cleanup happens.
99    ///
100    /// # Panics
101    ///
102    /// Panics if the guard has already been consumed (double-release).
103    pub fn release(mut self) -> Object<TcpManager> {
104        self.released = true;
105        self.conn
106            .take()
107            .expect("ConnectionGuard::release() called on consumed guard")
108    }
109
110    /// Close and remove the connection without applying replacement cooldown.
111    ///
112    /// Use this when the socket is dirty from a client-side abort, but the
113    /// backend did not fail and should not have its pool capacity reduced.
114    ///
115    /// # Panics
116    ///
117    /// Panics if the guard has already been consumed.
118    pub fn retire_without_cooldown(mut self) {
119        self.released = true;
120        let conn = self
121            .conn
122            .take()
123            .expect("ConnectionGuard::retire_without_cooldown() called on consumed guard");
124        self.provider.remove_without_cooldown(conn);
125    }
126
127    /// Close and remove the connection, applying replacement cooldown.
128    ///
129    /// Use this only when the backend connection itself failed or is known to be
130    /// in a backend-error state. Client-side disconnects and local dirty-socket
131    /// retirement should use `retire_without_cooldown`.
132    ///
133    /// # Panics
134    ///
135    /// Panics if the guard has already been consumed.
136    pub fn retire_with_cooldown(mut self) {
137        self.released = true;
138        let conn = self
139            .conn
140            .take()
141            .expect("ConnectionGuard::retire_with_cooldown() called on consumed guard");
142        self.provider.remove_with_cooldown(conn);
143    }
144
145    /// Get mutable reference to the connection
146    ///
147    /// # Panics
148    ///
149    /// Panics if the guard has already been consumed.
150    pub const fn get_mut(&mut self) -> &mut Object<TcpManager> {
151        self.conn
152            .as_mut()
153            .expect("ConnectionGuard already consumed")
154    }
155
156    /// Get shared reference to the connection
157    ///
158    /// # Panics
159    ///
160    /// Panics if the guard has already been consumed.
161    pub const fn get(&self) -> &Object<TcpManager> {
162        self.conn
163            .as_ref()
164            .expect("ConnectionGuard already consumed")
165    }
166
167    #[must_use]
168    pub fn connection_type(&self) -> &'static str {
169        self.get().connection_type()
170    }
171
172    #[must_use]
173    pub fn pending_bytes_len(&self) -> usize {
174        self.get().pending_bytes_len()
175    }
176
177    #[must_use]
178    pub fn provider_status_counts(&self) -> crate::pool::provider::DeadpoolStatusCounts {
179        self.provider.status_counts()
180    }
181
182    #[must_use]
183    pub fn provider_name(&self) -> &str {
184        self.provider.name()
185    }
186}
187
188impl Drop for ConnectionGuard {
189    fn drop(&mut self) {
190        if !self.released
191            && let Some(conn) = self.conn.take()
192        {
193            tracing::debug!(
194                connection_type = conn.connection_type(),
195                pending_bytes = conn.pending_bytes_len(),
196                "ConnectionGuard dropping unreleased pooled connection; removing backend connection with cooldown"
197            );
198            self.provider.remove_with_cooldown(conn);
199        }
200    }
201}
202
203impl std::ops::Deref for ConnectionGuard {
204    type Target = Object<TcpManager>;
205    fn deref(&self) -> &Self::Target {
206        self.get()
207    }
208}
209
210impl std::ops::DerefMut for ConnectionGuard {
211    fn deref_mut(&mut self) -> &mut Self::Target {
212        self.get_mut()
213    }
214}
215
216/// Salvage connection after Invalid response using DATE health check
217///
218/// Used when an Invalid response is detected - attempts to salvage the connection
219/// instead of immediately removing it. This helps prevent connection churn.
220///
221/// # Strategy
222/// Send DATE command to verify connection is clean and responsive. If pending bytes
223/// data exists in the stream, the DATE response will be corrupted and the check
224/// will fail - this is both faster (~1 RTT vs 10 seconds) and equally correct.
225///
226/// On success: return connection to pool (just drop it normally)
227/// On failure: use `remove_with_cooldown` to remove gracefully with backoff
228///
229/// # Arguments
230/// * `conn` - Pooled connection to verify
231/// * `provider` - Connection provider (used for `remove_with_cooldown` on failure)
232pub async fn salvage_with_health_check(
233    mut conn: Object<TcpManager>,
234    provider: DeadpoolConnectionProvider,
235) {
236    use tracing::{debug, warn};
237
238    match crate::pool::health_check::check_date_response(&mut *conn).await {
239        Ok(()) => {
240            debug!("Connection salvaged after Invalid response - DATE check passed");
241            drop(conn); // returns to pool
242        }
243        Err(e) => {
244            warn!("DATE health check failed after Invalid response: {}", e);
245            // Unconditional: this is a pool-level operation with no client involved.
246            // DATE failure means the connection is in an unknown/dirty state.
247            provider.remove_with_cooldown(conn);
248        }
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    // ─── ConnectionGuard pool-fate invariants ───────────────────────────────
255    //
256    // These tests verify two invariants that callers must rely on:
257    //
258    //   1. `release()` returns the connection to pool — pool can reuse it without
259    //      creating a new TCP connection to the backend.
260    //
261    //   2. drop without `release()` removes the connection — pool creates a fresh
262    //      TCP connection on the next `get()`.
263    //
264    // These invariants protect the A2 refactor: any call site that uses
265    // `ConnectionGuard` must call `release()` on "clean" paths (e.g. `ClientDisconnect`
266    // where the backend was drained successfully) and let the guard drop on
267    // "dirty" paths (backend errors, unknown state).
268    //
269    // A buggy A2 that drops the guard on `ClientDisconnect` without calling
270    // `release()` would cause release_reuses_pool_connection to fail — the pool
271    // would create a new TCP connection instead of reusing the existing one.
272
273    use super::ConnectionGuard;
274    use std::sync::Arc;
275    use std::sync::atomic::{AtomicUsize, Ordering};
276    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
277    use tokio::net::TcpListener;
278
279    /// Spawn a minimal mock NNTP greeting server (no auth, no compression).
280    ///
281    /// Returns `(port, accept_count)` where `accept_count` increments on each
282    /// TCP accept. The server:
283    ///   1. Sends `200 Ready\r\n` greeting
284    ///   2. Responds to `COMPRESS DEFLATE` with `500 Not supported\r\n`
285    ///      (required so `TcpManager::create()` completes compression negotiation)
286    ///   3. Keeps the connection open until the client closes it
287    async fn spawn_greeting_server() -> (u16, Arc<AtomicUsize>) {
288        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
289        let port = listener.local_addr().unwrap().port();
290        let accept_count = Arc::new(AtomicUsize::new(0));
291        let count = Arc::clone(&accept_count);
292
293        tokio::spawn(async move {
294            while let Ok((stream, _)) = listener.accept().await {
295                count.fetch_add(1, Ordering::SeqCst);
296                tokio::spawn(async move {
297                    let (read_half, mut write_half) = stream.into_split();
298                    let mut reader = BufReader::new(read_half);
299
300                    // Send NNTP greeting
301                    if write_half.write_all(b"200 Ready\r\n").await.is_err() {
302                        return;
303                    }
304
305                    // Handle TcpManager setup commands, then keep alive.
306                    let mut line = String::new();
307                    loop {
308                        line.clear();
309                        match reader.read_line(&mut line).await {
310                            Ok(0) | Err(_) => break, // Client closed connection
311                            Ok(_) => {
312                                let cmd = line.trim().to_ascii_uppercase();
313                                if cmd == "COMPRESS DEFLATE" {
314                                    let _ = write_half.write_all(b"500 Not supported\r\n").await;
315                                } else if cmd.starts_with("MODE") {
316                                    let _ = write_half.write_all(b"200 Posting allowed\r\n").await;
317                                } else if cmd.starts_with("QUIT") {
318                                    let _ = write_half.write_all(b"205 Goodbye\r\n").await;
319                                    break;
320                                } else if cmd.starts_with("DATE") {
321                                    let _ = write_half.write_all(b"111 20240101000000\r\n").await;
322                                } else {
323                                    let _ = write_half.write_all(b"200 OK\r\n").await;
324                                }
325                            }
326                        }
327                    }
328                });
329            }
330        });
331
332        (port, accept_count)
333    }
334
335    fn make_provider(port: u16) -> crate::pool::DeadpoolConnectionProvider {
336        crate::pool::DeadpoolConnectionProvider::builder("127.0.0.1", port)
337            .max_connections(5)
338            .build()
339            .unwrap()
340    }
341
342    /// Invariant: `release()` returns the connection to the pool.
343    ///
344    /// Pool must reuse the connection without creating a new TCP connection.
345    /// This is the path taken on success and on `ClientDisconnect` (backend was
346    /// cleanly drained — connection is still valid).
347    #[tokio::test]
348    async fn release_reuses_pool_connection() {
349        let (port, accept_count) = spawn_greeting_server().await;
350        let provider = make_provider(port);
351
352        // First get — establishes TCP connection #1
353        let conn = provider.get_pooled_connection().await.unwrap();
354        assert_eq!(accept_count.load(Ordering::SeqCst), 1);
355
356        // release() returns conn to pool (no shutdown)
357        let guard = ConnectionGuard::new(conn, provider.clone());
358        drop(guard.release());
359
360        // Second get — pool recycles the existing connection (no new TCP handshake)
361        let _conn2 = provider.get_pooled_connection().await.unwrap();
362        assert_eq!(
363            accept_count.load(Ordering::SeqCst),
364            1,
365            "release() must return connection to pool; next get() must reuse it without \
366             creating a new TCP connection"
367        );
368    }
369
370    /// Invariant: drop without `release()` removes the connection from the pool.
371    ///
372    /// The guard shuts down the socket; pool recycle detects EOF
373    /// and discards it; next `get()` creates a fresh TCP connection.
374    /// Unknown/backend-error drop paths apply replacement cooldown.
375    #[tokio::test]
376    async fn drop_without_release_forces_new_connection() {
377        let (port, accept_count) = spawn_greeting_server().await;
378        let provider = make_provider(port);
379
380        // First get — establishes TCP connection #1
381        let conn = provider.get_pooled_connection().await.unwrap();
382        assert_eq!(accept_count.load(Ordering::SeqCst), 1);
383
384        // Drop without release → socket shut down
385        let guard = ConnectionGuard::new(conn, provider.clone());
386        drop(guard);
387
388        // remove_with_cooldown calls socket2::shutdown(Both) synchronously, so the OS
389        // has already marked the fd as EOF. However, tokio's non-blocking try_read()
390        // inside check_tcp_alive only returns Ok(0) once the tokio I/O driver has
391        // processed the POLLIN event from epoll. That requires the runtime to park
392        // (epoll_wait). A short sleep causes the current task to suspend, the runtime
393        // parks, epoll delivers the event, and the socket is marked readable (EOF) —
394        // so the next recycle() correctly detects the dead connection and discards it.
395        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
396
397        // Second get — pool recycles, check_tcp_alive detects EOF, removes it,
398        // creates a new TCP connection (#2)
399        let _conn2 = provider.get_pooled_connection().await.unwrap();
400        assert_eq!(
401            accept_count.load(Ordering::SeqCst),
402            2,
403            "drop without release() must remove connection; next get() must create \
404             a new TCP connection"
405        );
406    }
407
408    // ─── salvage_with_health_check notes ────────────────────────────────────
409
410    // Full integration tests for salvage_with_health_check would require
411    // complex mocking of pooled connections with pending data. It is tested
412    // indirectly through the command_execution integration tests.
413    //
414    // The constituent part check_date_response has its own tests in health_check.rs.
415}