nntp-proxy 0.5.1

NNTP proxy server with per-command backend multiplexing, caching, metrics, and TUI dashboard
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
//! Connection guard for automatic cleanup of broken pooled connections
//!
//! This module provides utilities to automatically remove broken connections from
//! the pool when I/O errors occur, preventing stale connections from being recycled.
//!
//! # CRITICAL: Connection Hold Time Guarantees
//!
//! All connection salvage operations MUST complete in <=1 second to prevent pool
//! starvation and throughput collapse. This is enforced by:
//! - Compile-time const assertions on timeout values
//! - Type-level guarantees preventing timeout loops

use deadpool::managed::Object;

use crate::constants::pool::HEALTH_CHECK_TIMEOUT;
use crate::pool::deadpool_connection::TcpManager;
use crate::pool::provider::DeadpoolConnectionProvider;

// ═══════════════════════════════════════════════════════════════════════════
// COMPILE-TIME SAFEGUARDS: Connection Hold Time Limits
// ═══════════════════════════════════════════════════════════════════════════

/// Maximum time (milliseconds) any connection salvage operation can take
///
/// CRITICAL: If salvage takes longer than this, we risk pool starvation and
/// throughput collapse. This constant is enforced by:
/// - Const assertions below
/// - Code review guidelines
///
/// Background: This bound must stay aligned with the configured DATE health-check timeout.
pub const MAX_CONNECTION_SALVAGE_MS: u64 = 1_000;

/// COMPILE-TIME ASSERTION: Prevent timeout loops from being added
///
/// This `const fn` exists purely to create a compile error if someone tries to add
/// a timeout loop. Any loop with `MAX_ITERATIONS > 1` will fail this assertion.
///
/// Example that will NOT compile:
/// ```compile_fail
/// const MAX_DRAIN_ITERATIONS: usize = 50; // FAILS ASSERTION
/// const DRAIN_TIMEOUT_MS: u64 = 200;
/// const _: () = assert_no_timeout_loop(MAX_DRAIN_ITERATIONS, DRAIN_TIMEOUT_MS);
/// ```
#[allow(dead_code)] // Called from a const assertion; rustc still reports the helper as unused.
const fn assert_no_timeout_loop(max_iterations: usize, timeout_per_iteration_ms: u64) {
    // If you see this compile error, you're trying to add a timeout loop
    // that could hold connections for too long. Use DATE health check instead.
    assert!(
        max_iterations == 1,
        "Connection salvage MUST NOT use timeout loops (max_iterations must be 1)"
    );
    assert!(
        timeout_per_iteration_ms <= MAX_CONNECTION_SALVAGE_MS,
        "Single timeout must be <= MAX_CONNECTION_SALVAGE_MS"
    );
}

// Apply assertion to salvage_with_health_check (implicit: it has no loop)
const _SALVAGE_NO_LOOP: () = {
    // salvage_with_health_check has exactly 1 operation (DATE check)
    // Compare as u128 to avoid truncating cast (as_millis() returns u128; safe for any
    // reasonable timeout value, but avoids the footgun entirely)
    assert!(
        HEALTH_CHECK_TIMEOUT.as_millis() <= MAX_CONNECTION_SALVAGE_MS as u128,
        "HEALTH_CHECK_TIMEOUT must be <= MAX_CONNECTION_SALVAGE_MS"
    );
    // Assert exactly 1 operation (no loop)
    assert_no_timeout_loop(1, MAX_CONNECTION_SALVAGE_MS);
};

/// RAII guard for pooled connections.
///
/// Automatically removes an unreleased connection from the pool on drop. Drop
/// applies replacement cooldown because unreleased guards represent unknown or
/// backend-error state. Use `retire_without_cooldown` for known client-side
/// dirty sockets that should not throttle backend replacement.
///
/// Follows the same pattern as `CommandGuard` from `src/router/mod.rs`.
pub struct ConnectionGuard {
    conn: Option<Object<TcpManager>>,
    provider: DeadpoolConnectionProvider,
    released: bool,
}

impl ConnectionGuard {
    /// Create a new guard (removes from pool on drop unless released).
    pub const fn new(conn: Object<TcpManager>, provider: DeadpoolConnectionProvider) -> Self {
        Self {
            conn: Some(conn),
            provider,
            released: false,
        }
    }

    /// Return connection to pool (healthy).
    ///
    /// Connection will be returned to the pool normally when the returned
    /// `Object` is dropped. The guard is consumed — no cleanup happens.
    ///
    /// # Panics
    ///
    /// Panics if the guard has already been consumed (double-release).
    pub fn release(mut self) -> Object<TcpManager> {
        self.released = true;
        self.conn
            .take()
            .expect("ConnectionGuard::release() called on consumed guard")
    }

    /// Close and remove the connection without applying replacement cooldown.
    ///
    /// Use this when the socket is dirty from a client-side abort, but the
    /// backend did not fail and should not have its pool capacity reduced.
    ///
    /// # Panics
    ///
    /// Panics if the guard has already been consumed.
    pub fn retire_without_cooldown(mut self) {
        self.released = true;
        let conn = self
            .conn
            .take()
            .expect("ConnectionGuard::retire_without_cooldown() called on consumed guard");
        self.provider.remove_without_cooldown(conn);
    }

    /// Close and remove the connection, applying replacement cooldown.
    ///
    /// Use this only when the backend connection itself failed or is known to be
    /// in a backend-error state. Client-side disconnects and local dirty-socket
    /// retirement should use `retire_without_cooldown`.
    ///
    /// # Panics
    ///
    /// Panics if the guard has already been consumed.
    pub fn retire_with_cooldown(mut self) {
        self.released = true;
        let conn = self
            .conn
            .take()
            .expect("ConnectionGuard::retire_with_cooldown() called on consumed guard");
        self.provider.remove_with_cooldown(conn);
    }

    /// Get mutable reference to the connection
    ///
    /// # Panics
    ///
    /// Panics if the guard has already been consumed.
    pub const fn get_mut(&mut self) -> &mut Object<TcpManager> {
        self.conn
            .as_mut()
            .expect("ConnectionGuard already consumed")
    }

    /// Get shared reference to the connection
    ///
    /// # Panics
    ///
    /// Panics if the guard has already been consumed.
    pub const fn get(&self) -> &Object<TcpManager> {
        self.conn
            .as_ref()
            .expect("ConnectionGuard already consumed")
    }

    #[must_use]
    pub fn connection_type(&self) -> &'static str {
        self.get().connection_type()
    }

    #[must_use]
    pub fn pending_bytes_len(&self) -> usize {
        self.get().pending_bytes_len()
    }

    #[must_use]
    pub fn provider_status_counts(&self) -> crate::pool::provider::DeadpoolStatusCounts {
        self.provider.status_counts()
    }

    #[must_use]
    pub fn provider_name(&self) -> &str {
        self.provider.name()
    }
}

impl Drop for ConnectionGuard {
    fn drop(&mut self) {
        if !self.released
            && let Some(conn) = self.conn.take()
        {
            tracing::debug!(
                connection_type = conn.connection_type(),
                pending_bytes = conn.pending_bytes_len(),
                "ConnectionGuard dropping unreleased pooled connection; removing backend connection with cooldown"
            );
            self.provider.remove_with_cooldown(conn);
        }
    }
}

impl std::ops::Deref for ConnectionGuard {
    type Target = Object<TcpManager>;
    fn deref(&self) -> &Self::Target {
        self.get()
    }
}

impl std::ops::DerefMut for ConnectionGuard {
    fn deref_mut(&mut self) -> &mut Self::Target {
        self.get_mut()
    }
}

/// Salvage connection after Invalid response using DATE health check
///
/// Used when an Invalid response is detected - attempts to salvage the connection
/// instead of immediately removing it. This helps prevent connection churn.
///
/// # Strategy
/// Send DATE command to verify connection is clean and responsive. If pending bytes
/// data exists in the stream, the DATE response will be corrupted and the check
/// will fail - this is both faster (~1 RTT vs 10 seconds) and equally correct.
///
/// On success: return connection to pool (just drop it normally)
/// On failure: use `remove_with_cooldown` to remove gracefully with backoff
///
/// # Arguments
/// * `conn` - Pooled connection to verify
/// * `provider` - Connection provider (used for `remove_with_cooldown` on failure)
pub async fn salvage_with_health_check(
    mut conn: Object<TcpManager>,
    provider: DeadpoolConnectionProvider,
) {
    use tracing::{debug, warn};

    match crate::pool::health_check::check_date_response(&mut *conn).await {
        Ok(()) => {
            debug!("Connection salvaged after Invalid response - DATE check passed");
            drop(conn); // returns to pool
        }
        Err(e) => {
            warn!("DATE health check failed after Invalid response: {}", e);
            // Unconditional: this is a pool-level operation with no client involved.
            // DATE failure means the connection is in an unknown/dirty state.
            provider.remove_with_cooldown(conn);
        }
    }
}

#[cfg(test)]
mod tests {
    // ─── ConnectionGuard pool-fate invariants ───────────────────────────────
    //
    // These tests verify two invariants that callers must rely on:
    //
    //   1. `release()` returns the connection to pool — pool can reuse it without
    //      creating a new TCP connection to the backend.
    //
    //   2. drop without `release()` removes the connection — pool creates a fresh
    //      TCP connection on the next `get()`.
    //
    // These invariants protect the A2 refactor: any call site that uses
    // `ConnectionGuard` must call `release()` on "clean" paths (e.g. `ClientDisconnect`
    // where the backend was drained successfully) and let the guard drop on
    // "dirty" paths (backend errors, unknown state).
    //
    // A buggy A2 that drops the guard on `ClientDisconnect` without calling
    // `release()` would cause release_reuses_pool_connection to fail — the pool
    // would create a new TCP connection instead of reusing the existing one.

    use super::ConnectionGuard;
    use std::sync::Arc;
    use std::sync::atomic::{AtomicUsize, Ordering};
    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
    use tokio::net::TcpListener;

    /// Spawn a minimal mock NNTP greeting server (no auth, no compression).
    ///
    /// Returns `(port, accept_count)` where `accept_count` increments on each
    /// TCP accept. The server:
    ///   1. Sends `200 Ready\r\n` greeting
    ///   2. Responds to `COMPRESS DEFLATE` with `500 Not supported\r\n`
    ///      (required so `TcpManager::create()` completes compression negotiation)
    ///   3. Keeps the connection open until the client closes it
    async fn spawn_greeting_server() -> (u16, Arc<AtomicUsize>) {
        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = listener.local_addr().unwrap().port();
        let accept_count = Arc::new(AtomicUsize::new(0));
        let count = Arc::clone(&accept_count);

        tokio::spawn(async move {
            while let Ok((stream, _)) = listener.accept().await {
                count.fetch_add(1, Ordering::SeqCst);
                tokio::spawn(async move {
                    let (read_half, mut write_half) = stream.into_split();
                    let mut reader = BufReader::new(read_half);

                    // Send NNTP greeting
                    if write_half.write_all(b"200 Ready\r\n").await.is_err() {
                        return;
                    }

                    // Handle TcpManager setup commands, then keep alive.
                    let mut line = String::new();
                    loop {
                        line.clear();
                        match reader.read_line(&mut line).await {
                            Ok(0) | Err(_) => break, // Client closed connection
                            Ok(_) => {
                                let cmd = line.trim().to_ascii_uppercase();
                                if cmd == "COMPRESS DEFLATE" {
                                    let _ = write_half.write_all(b"500 Not supported\r\n").await;
                                } else if cmd.starts_with("MODE") {
                                    let _ = write_half.write_all(b"200 Posting allowed\r\n").await;
                                } else if cmd.starts_with("QUIT") {
                                    let _ = write_half.write_all(b"205 Goodbye\r\n").await;
                                    break;
                                } else if cmd.starts_with("DATE") {
                                    let _ = write_half.write_all(b"111 20240101000000\r\n").await;
                                } else {
                                    let _ = write_half.write_all(b"200 OK\r\n").await;
                                }
                            }
                        }
                    }
                });
            }
        });

        (port, accept_count)
    }

    fn make_provider(port: u16) -> crate::pool::DeadpoolConnectionProvider {
        crate::pool::DeadpoolConnectionProvider::builder("127.0.0.1", port)
            .max_connections(5)
            .build()
            .unwrap()
    }

    /// Invariant: `release()` returns the connection to the pool.
    ///
    /// Pool must reuse the connection without creating a new TCP connection.
    /// This is the path taken on success and on `ClientDisconnect` (backend was
    /// cleanly drained — connection is still valid).
    #[tokio::test]
    async fn release_reuses_pool_connection() {
        let (port, accept_count) = spawn_greeting_server().await;
        let provider = make_provider(port);

        // First get — establishes TCP connection #1
        let conn = provider.get_pooled_connection().await.unwrap();
        assert_eq!(accept_count.load(Ordering::SeqCst), 1);

        // release() returns conn to pool (no shutdown)
        let guard = ConnectionGuard::new(conn, provider.clone());
        drop(guard.release());

        // Second get — pool recycles the existing connection (no new TCP handshake)
        let _conn2 = provider.get_pooled_connection().await.unwrap();
        assert_eq!(
            accept_count.load(Ordering::SeqCst),
            1,
            "release() must return connection to pool; next get() must reuse it without \
             creating a new TCP connection"
        );
    }

    /// Invariant: drop without `release()` removes the connection from the pool.
    ///
    /// The guard shuts down the socket; pool recycle detects EOF
    /// and discards it; next `get()` creates a fresh TCP connection.
    /// Unknown/backend-error drop paths apply replacement cooldown.
    #[tokio::test]
    async fn drop_without_release_forces_new_connection() {
        let (port, accept_count) = spawn_greeting_server().await;
        let provider = make_provider(port);

        // First get — establishes TCP connection #1
        let conn = provider.get_pooled_connection().await.unwrap();
        assert_eq!(accept_count.load(Ordering::SeqCst), 1);

        // Drop without release → socket shut down
        let guard = ConnectionGuard::new(conn, provider.clone());
        drop(guard);

        // remove_with_cooldown calls socket2::shutdown(Both) synchronously, so the OS
        // has already marked the fd as EOF. However, tokio's non-blocking try_read()
        // inside check_tcp_alive only returns Ok(0) once the tokio I/O driver has
        // processed the POLLIN event from epoll. That requires the runtime to park
        // (epoll_wait). A short sleep causes the current task to suspend, the runtime
        // parks, epoll delivers the event, and the socket is marked readable (EOF) —
        // so the next recycle() correctly detects the dead connection and discards it.
        tokio::time::sleep(std::time::Duration::from_millis(10)).await;

        // Second get — pool recycles, check_tcp_alive detects EOF, removes it,
        // creates a new TCP connection (#2)
        let _conn2 = provider.get_pooled_connection().await.unwrap();
        assert_eq!(
            accept_count.load(Ordering::SeqCst),
            2,
            "drop without release() must remove connection; next get() must create \
             a new TCP connection"
        );
    }

    // ─── salvage_with_health_check notes ────────────────────────────────────

    // Full integration tests for salvage_with_health_check would require
    // complex mocking of pooled connections with pending data. It is tested
    // indirectly through the command_execution integration tests.
    //
    // The constituent part check_date_response has its own tests in health_check.rs.
}