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}