Skip to main content

nzb_dispatch/
download_engine.rs

1//! Download engine — shared NNTP worker pool that services all active jobs.
2//!
3//! Architecture:
4//! - A single long-lived [`WorkerPool`] is owned by [`crate::queue_manager::QueueManager`].
5//! - For each enabled server, exactly `server.connections` workers are
6//!   spawned and live as long as the server stays enabled. When the server
7//!   list or per-server connection limit changes, the pool reconciles.
8//! - Jobs register a [`JobContext`] and push their work items into a
9//!   [`SharedWorkQueue`]. Workers pop items tagged with `job_id` and look up
10//!   per-job state (assembler, progress sink, pause/cancel flags) via the
11//!   shared [`JobContextMap`].
12//! - Pause / cancel / completion are per-job flags; workers themselves are
13//!   never torn down on job transitions. Pausing a job causes workers holding
14//!   one of its items to return that item to the queue and pull something
15//!   else. Cancelling a job drains its items and drops in-flight results.
16//! - Transient provider outages leave work queued until circuit-breaker
17//!   cooldown expires; only definitive provider outcomes resolve an article.
18//!
19//! Retry logic (per article):
20//! 1. Try the article on the current server up to [`MAX_TRIES_PER_SERVER`]
21//!    times, reconnecting on transient errors.
22//! 2. On `ArticleNotFound` (430) — requeue with the current server added to
23//!    `tried_servers`; another worker on a different server picks it up.
24//! 3. On connection loss — requeue and reconnect.
25//! 4. On decode error — treated like "not available on this server", try
26//!    another.
27//! 5. An article is missing only when every enabled provider explicitly
28//!    returned `430`. Circuit-broken and transiently failing providers do
29//!    not contribute evidence of absence.
30//! 6. A job only fails if failed articles exceed the threshold and no par2
31//!    recovery is possible.
32
33use std::collections::{HashMap, VecDeque};
34use std::path::PathBuf;
35use std::sync::Arc;
36use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
37use std::time::{Duration, Instant};
38
39use parking_lot::Mutex;
40use tokio::sync::{Notify, mpsc};
41use tokio::task::JoinHandle;
42use tracing::{debug, error, info, trace, warn};
43
44use nzb_core::config::ServerConfig;
45use nzb_core::models::NzbJob;
46use nzb_decode::FileAssembler;
47use nzb_decode::yenc::decode_yenc;
48use nzb_nntp::Pipeline;
49use nzb_nntp::connection::{ConnectionState, NntpConnection};
50use nzb_nntp::error::NntpError;
51
52use crate::bandwidth::BandwidthLimiter;
53
54fn increment_counter(name: &'static str) {
55    opentelemetry::global::meter_provider()
56        .meter("nzb-dispatch")
57        .u64_counter(name)
58        .build()
59        .add(1, &[]);
60}
61
62// ---------------------------------------------------------------------------
63// Constants
64// ---------------------------------------------------------------------------
65
66/// Max times to retry an article on the SAME server before trying the next.
67const MAX_TRIES_PER_SERVER: u32 = 3;
68/// How many timeouts, dropped connections or unclassified errors one article
69/// absorbs on a server that is otherwise up, before the error counts as that
70/// server's definitive outcome. A server-wide outage (502, 480, 403) is not
71/// counted: the circuit breaker handles those.
72const MAX_OUTAGE_TRIES_PER_SERVER: u32 = 10;
73/// Delay between reconnection attempts.
74const RECONNECT_DELAY: Duration = Duration::from_secs(5);
75/// Max reconnect attempts before giving up on a server for this session.
76const MAX_RECONNECT_ATTEMPTS: u32 = 5;
77/// Stagger delay between worker initial connections to avoid thundering herd.
78/// Each worker waits conn_idx * WORKER_RAMP_DELAY before its first connect.
79const WORKER_RAMP_DELAY: Duration = Duration::from_millis(15);
80/// Consecutive connect failures before circuit-breaking a server.
81const CIRCUIT_BREAK_THRESHOLD: u32 = 3;
82/// Cooldown after auth/permission failure (bad credentials, 502, account blocked).
83const AUTH_FAILURE_COOLDOWN: Duration = Duration::from_secs(120);
84/// Cooldown after transient connection failures exceed threshold.
85const TRANSIENT_FAILURE_COOLDOWN: Duration = Duration::from_secs(30);
86/// Supervisor tick interval for detecting stuck jobs.
87const SUPERVISOR_INTERVAL: Duration = Duration::from_secs(1);
88
89/// Default maximum idle time before a worker is evicted by the supervisor.
90/// Tunable per pool via [`WorkerPool::set_max_worker_idle`]. Production code
91/// uses the default; the test harness shrinks this to make Phase 5 tests
92/// converge in seconds rather than minutes.
93const DEFAULT_MAX_WORKER_IDLE: Duration = Duration::from_secs(60);
94/// Worker idle poll interval when the shared queue is empty.
95const WORKER_IDLE_POLL: Duration = Duration::from_millis(500);
96
97/// Phase 7: capacity of the per-job progress channel. The handler reads
98/// this at ~articles per second; under DB-lock contention or
99/// post-processing pauses it can fall behind. With this cap, the worst
100/// case is bounded buffering plus a `WARN` from
101/// [`try_send_progress`] when the channel is full.
102pub const PROGRESS_CHANNEL_CAPACITY: usize = 10_000;
103
104/// Send a progress update to the per-job channel. On `Full` (handler
105/// backpressure), log a warning and drop the update — the alternative is
106/// awaiting and stalling the worker, which would cascade into the entire
107/// download pipeline. On `Closed` (handler shut down), drop silently.
108///
109/// Workers should call this through their `JobContext` rather than
110/// touching `progress_tx` directly.
111fn try_send_progress(tx: &mpsc::Sender<ProgressUpdate>, job_id: &str, update: ProgressUpdate) {
112    if let Err(e) = tx.try_send(update) {
113        match e {
114            mpsc::error::TrySendError::Full(_) => {
115                warn!(
116                    job_id,
117                    capacity = PROGRESS_CHANNEL_CAPACITY,
118                    "Progress channel full — dropping update (handler backpressure)"
119                );
120            }
121            mpsc::error::TrySendError::Closed(_) => {
122                // Handler has shut down. Workers can't recover this state;
123                // dropping is correct.
124            }
125        }
126    }
127}
128
129// ---------------------------------------------------------------------------
130// Global connection tracking — semaphore-backed permit pools
131// ---------------------------------------------------------------------------
132
133/// Tracks the per-server NNTP connection budget. Each server has a
134/// `tokio::sync::Semaphore` whose initial permit count is the configured
135/// `connections` limit. Workers acquire a permit *before* connecting and
136/// hold it for their entire lifetime; the permit's `Drop` releases the slot
137/// synchronously back to the pool. This makes over-allocation a type-level
138/// impossibility — once `limit` permits are out, the next acquire awaits.
139///
140/// **Limit changes at runtime** are handled differently for grow vs shrink:
141///
142/// - **Grow** (e.g. 5 → 10): the existing semaphore is given the additional
143///   permits via `add_permits`; existing slot holders are unaffected.
144/// - **Shrink** (e.g. 10 → 5): the entire `ServerSlot` is replaced with a
145///   fresh one. Old permit holders continue to reference the orphaned
146///   semaphore via their `Arc`; their drops release back to it (a no-op
147///   since nothing else points at it). Workers detect the replacement via
148///   [`ConnectionTracker::slot_is_current`] and exit on the next iteration.
149pub struct ConnectionTracker {
150    pools: Mutex<HashMap<String, ServerSlot>>,
151}
152
153#[derive(Clone)]
154struct ServerSlot {
155    name: String,
156    limit: usize,
157    semaphore: Arc<tokio::sync::Semaphore>,
158    connected: Arc<AtomicUsize>,
159}
160
161impl ConnectionTracker {
162    pub fn new() -> Self {
163        Self {
164            pools: Mutex::new(HashMap::new()),
165        }
166    }
167
168    /// Set or update the per-server connection limit.
169    ///
170    /// - First call for a `server_id`: creates a fresh semaphore with `limit` permits.
171    /// - Subsequent call with the same `limit` and `server_name`: no-op.
172    /// - Grow (`limit > current`): adds permits in place.
173    /// - Shrink or rename: replaces the slot. Old permit holders detach
174    ///   naturally via [`slot_is_current`].
175    pub fn set_limit(&self, server_id: &str, server_name: &str, limit: usize) {
176        let mut pools = self.pools.lock();
177        match pools.get_mut(server_id) {
178            Some(slot) if slot.limit == limit && slot.name == server_name => {
179                // No change.
180            }
181            Some(slot) if limit > slot.limit && slot.name == server_name => {
182                let added = limit - slot.limit;
183                let old = slot.limit;
184                slot.semaphore.add_permits(added);
185                slot.limit = limit;
186                info!(
187                    server_id,
188                    server = %server_name,
189                    old_limit = old,
190                    new_limit = limit,
191                    added,
192                    "Connection pool grew in place"
193                );
194            }
195            existing => {
196                // Capture diagnostics before re-borrowing `pools` for insert.
197                let (prev_limit, prev_name) = match existing {
198                    Some(s) => (Some(s.limit), Some(s.name.clone())),
199                    None => (None, None),
200                };
201                pools.insert(
202                    server_id.to_string(),
203                    ServerSlot {
204                        name: server_name.to_string(),
205                        limit,
206                        semaphore: Arc::new(tokio::sync::Semaphore::new(limit)),
207                        connected: Arc::new(AtomicUsize::new(0)),
208                    },
209                );
210                if let Some(prev) = prev_limit {
211                    let renamed = prev_name.as_deref() != Some(server_name);
212                    info!(
213                        server_id,
214                        server = %server_name,
215                        old_limit = prev,
216                        new_limit = limit,
217                        renamed,
218                        "Connection pool replaced (shrink or rename); old permits orphaned"
219                    );
220                } else {
221                    info!(
222                        server_id,
223                        server = %server_name,
224                        limit,
225                        "Connection pool created"
226                    );
227                }
228            }
229        }
230    }
231
232    /// Forget a server entirely (e.g. on `update_servers` removing it).
233    /// Existing permit holders are unaffected; they will detect that their
234    /// slot is no longer current and exit.
235    pub fn remove_server(&self, server_id: &str) {
236        self.pools.lock().remove(server_id);
237    }
238
239    /// Acquire a connection slot for `server_id`. Awaits if the pool is at
240    /// the limit. Returns `None` if the server isn't registered or its
241    /// limit is zero.
242    pub async fn acquire(&self, server_id: &str) -> Option<ConnectionSlot> {
243        // Snapshot the ServerSlot under lock, release the lock before await.
244        let server_slot = {
245            let pools = self.pools.lock();
246            pools.get(server_id).cloned()?
247        };
248        if server_slot.limit == 0 {
249            return None;
250        }
251        let permit = Arc::clone(&server_slot.semaphore)
252            .acquire_owned()
253            .await
254            .ok()?;
255        Some(ConnectionSlot {
256            server_id: server_id.to_string(),
257            server_name: server_slot.name,
258            semaphore_origin: server_slot.semaphore,
259            connected: server_slot.connected,
260            is_connected: false,
261            _permit: permit,
262        })
263    }
264
265    /// Returns true if `slot` was acquired from the *current* semaphore for
266    /// its server. False if the limit was changed (semaphore replaced) or
267    /// the server was removed — the worker should exit at its next safe
268    /// checkpoint.
269    pub fn slot_is_current(&self, slot: &ConnectionSlot) -> bool {
270        matches!(self.slot_status(slot), SlotStatus::Current)
271    }
272
273    /// Like [`slot_is_current`] but distinguishes the reason a slot is no
274    /// longer current — useful for diagnostics on the worker exit path.
275    pub fn slot_status(&self, slot: &ConnectionSlot) -> SlotStatus {
276        let pools = self.pools.lock();
277        match pools.get(&slot.server_id) {
278            Some(server_slot) => {
279                if Arc::ptr_eq(&server_slot.semaphore, &slot.semaphore_origin) {
280                    SlotStatus::Current
281                } else {
282                    SlotStatus::PoolReplaced
283                }
284            }
285            None => SlotStatus::ServerRemoved,
286        }
287    }
288
289    /// `(server_id, active, limit)` triples for the live pool. `active` is
290    /// derived from the semaphore's available permits and is always
291    /// `<= limit` by construction.
292    pub fn snapshot(&self) -> Vec<(String, usize, usize)> {
293        let pools = self.pools.lock();
294        pools
295            .iter()
296            .map(|(id, slot)| {
297                let active = slot
298                    .limit
299                    .saturating_sub(slot.semaphore.available_permits());
300                (id.clone(), active, slot.limit)
301            })
302            .collect()
303    }
304
305    /// `(server_id, active, limit)` triples for sockets currently transferring
306    /// an article. Authenticated sockets waiting for work are idle/free.
307    pub fn connected_snapshot(&self) -> Vec<(String, usize, usize)> {
308        let pools = self.pools.lock();
309        pools
310            .iter()
311            .map(|(id, slot)| {
312                (
313                    id.clone(),
314                    slot.connected.load(Ordering::Relaxed).min(slot.limit),
315                    slot.limit,
316                )
317            })
318            .collect()
319    }
320
321    /// Total currently-held permits across all servers in the live pool.
322    /// Permits held against orphaned (replaced) semaphores are NOT counted —
323    /// they'll go away as the holding workers exit.
324    pub fn total(&self) -> usize {
325        let pools = self.pools.lock();
326        pools
327            .values()
328            .map(|s| s.limit.saturating_sub(s.semaphore.available_permits()))
329            .sum()
330    }
331}
332
333impl Default for ConnectionTracker {
334    fn default() -> Self {
335        Self::new()
336    }
337}
338
339/// Why a `ConnectionSlot` may no longer match its server's live pool.
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub enum SlotStatus {
342    /// Slot is still attached to the current semaphore for its server.
343    Current,
344    /// The semaphore was replaced (set_limit shrunk or rebuilt the pool).
345    /// Old permit holders should exit at their next safe checkpoint.
346    PoolReplaced,
347    /// The server has been removed entirely from the tracker.
348    ServerRemoved,
349}
350
351/// RAII handle for one acquired NNTP connection slot. The underlying
352/// semaphore permit is released synchronously on drop. Workers should hold
353/// a `ConnectionSlot` for their entire lifetime — across reconnects, even
354/// across temporary connect failures — and only drop it when exiting.
355pub struct ConnectionSlot {
356    server_id: String,
357    server_name: String,
358    /// The Arc identity of the semaphore the permit was issued by.
359    /// Used by `ConnectionTracker::slot_is_current` to detect a stale slot
360    /// after a `set_limit` shrink (which replaces the semaphore).
361    semaphore_origin: Arc<tokio::sync::Semaphore>,
362    connected: Arc<AtomicUsize>,
363    is_connected: bool,
364    _permit: tokio::sync::OwnedSemaphorePermit,
365}
366
367impl ConnectionSlot {
368    pub fn server_id(&self) -> &str {
369        &self.server_id
370    }
371    pub fn server_name(&self) -> &str {
372        &self.server_name
373    }
374
375    fn mark_active(&mut self) {
376        if !self.is_connected {
377            self.connected.fetch_add(1, Ordering::Relaxed);
378            self.is_connected = true;
379        }
380    }
381
382    fn mark_inactive(&mut self) {
383        if self.is_connected {
384            self.connected.fetch_sub(1, Ordering::Relaxed);
385            self.is_connected = false;
386        }
387    }
388
389    fn activity(&mut self) -> ConnectionActivity<'_> {
390        self.mark_active();
391        ConnectionActivity { slot: self }
392    }
393}
394
395impl Drop for ConnectionSlot {
396    fn drop(&mut self) {
397        self.mark_inactive();
398    }
399}
400
401/// Marks a connection as actively transferring an article for the lifetime
402/// of the guard. An authenticated but idle socket does not consume a "used"
403/// connection in API/UI accounting.
404struct ConnectionActivity<'a> {
405    slot: &'a mut ConnectionSlot,
406}
407
408impl Drop for ConnectionActivity<'_> {
409    fn drop(&mut self) {
410        self.slot.mark_inactive();
411    }
412}
413
414// ---------------------------------------------------------------------------
415// Server health tracking (circuit breaker)
416// ---------------------------------------------------------------------------
417
418#[derive(Debug)]
419pub struct ServerHealth {
420    pub consecutive_failures: u32,
421    pub disabled_until: Option<Instant>,
422    pub reason: Option<String>,
423    pub is_auth_failure: bool,
424}
425
426impl Default for ServerHealth {
427    fn default() -> Self {
428        Self::new()
429    }
430}
431
432impl ServerHealth {
433    pub fn new() -> Self {
434        Self {
435            consecutive_failures: 0,
436            disabled_until: None,
437            reason: None,
438            is_auth_failure: false,
439        }
440    }
441
442    pub fn is_available(&self) -> bool {
443        match self.disabled_until {
444            None => true,
445            Some(until) => Instant::now() >= until,
446        }
447    }
448
449    pub fn record_failure(&mut self, is_auth: bool, reason: &str) {
450        self.consecutive_failures += 1;
451        self.is_auth_failure = is_auth;
452        self.reason = Some(reason.to_string());
453
454        if is_auth || self.consecutive_failures >= CIRCUIT_BREAK_THRESHOLD {
455            let cooldown = if is_auth {
456                AUTH_FAILURE_COOLDOWN
457            } else {
458                TRANSIENT_FAILURE_COOLDOWN
459            };
460            self.disabled_until = Some(Instant::now() + cooldown);
461        }
462    }
463
464    pub fn record_success(&mut self) {
465        *self = Self::new();
466    }
467}
468
469pub type ServerHealthMap = Arc<Mutex<HashMap<String, ServerHealth>>>;
470
471// ---------------------------------------------------------------------------
472// Progress update messages
473// ---------------------------------------------------------------------------
474
475#[derive(Debug, Clone)]
476pub enum ProgressUpdate {
477    /// Every enabled NNTP provider is temporarily unavailable. The job stays
478    /// active and will retry automatically when a provider recovers.
479    WaitingForProviders { job_id: String, message: String },
480    /// At least one NNTP provider recovered after a
481    /// [`ProgressUpdate::WaitingForProviders`] notification.
482    ProvidersAvailable { job_id: String },
483    ArticleComplete {
484        job_id: String,
485        file_id: String,
486        segment_number: u32,
487        decoded_bytes: u64,
488        file_complete: bool,
489        server_id: Option<String>,
490        /// Filename declared by the yEnc header. This lets the queue layer
491        /// recognize obfuscated PAR2 files before terminal completion.
492        yenc_filename: Option<String>,
493    },
494    /// An article could not be retrieved. `failure` carries the typed
495    /// classification of *why* (NotFound, ServerDown, AuthFailed, …).
496    /// See `crate::article_failure` for the taxonomy.
497    ArticleFailed {
498        job_id: String,
499        file_id: String,
500        segment_number: u32,
501        failure: crate::article_failure::ArticleFailure,
502    },
503    JobFinished {
504        job_id: String,
505        success: bool,
506        articles_failed: usize,
507        download_time_secs: f64,
508    },
509    JobAborted {
510        job_id: String,
511        reason: String,
512        articles_failed: usize,
513        download_time_secs: f64,
514    },
515}
516
517// ---------------------------------------------------------------------------
518// Work item
519// ---------------------------------------------------------------------------
520
521#[derive(Debug, Clone)]
522pub(crate) struct WorkItem {
523    pub(crate) job_id: String,
524    pub(crate) file_id: String,
525    pub(crate) filename: String,
526    pub(crate) message_id: String,
527    pub(crate) segment_number: u32,
528    /// Servers already tried for this article (by server ID).
529    pub(crate) tried_servers: Vec<String>,
530    /// Definitive per-provider outcomes. Transient failures are not recorded
531    /// because they provide no evidence about article availability.
532    pub(crate) provider_outcomes: HashMap<String, crate::article_failure::ArticleFailureKind>,
533    /// Number of attempts on the current server.
534    pub(crate) tries_on_current: u32,
535    /// Timeouts and dropped connections seen for this article while the
536    /// server was otherwise healthy. Reset when the article moves server.
537    pub(crate) outage_tries: u32,
538}
539
540// ---------------------------------------------------------------------------
541// Per-job context
542// ---------------------------------------------------------------------------
543
544/// Per-job state that workers reference via `item.job_id`.
545///
546/// Everything a worker needs to process an article for a specific job lives
547/// here. The queue manager owns one `Arc<JobContext>` per active job; the
548/// pool holds a clone in its [`JobContextMap`] so workers can look it up.
549pub(crate) struct JobContext {
550    pub job_id: String,
551    pub work_dir: PathBuf,
552    pub assembler: Arc<FileAssembler>,
553    pub progress_tx: mpsc::Sender<ProgressUpdate>,
554    pub yenc_names: Arc<Mutex<HashMap<String, String>>>,
555    pub nzb_filenames: HashMap<String, String>,
556    /// Articles that still need a definitive result (success or all-server
557    /// failure). When this reaches zero, `JobFinished` is emitted.
558    pub articles_remaining: AtomicUsize,
559    pub articles_failed: AtomicUsize,
560    pub paused: AtomicBool,
561    pub cancelled: AtomicBool,
562    /// Optional abort reason — if set when articles_remaining hits zero
563    /// (or when cancellation fires), `JobAborted` is emitted instead of
564    /// `JobFinished`.
565    pub abort_reason: Mutex<Option<String>>,
566    pub total_decode_us: Arc<AtomicU64>,
567    pub total_assemble_us: Arc<AtomicU64>,
568    pub total_articles_decoded: Arc<AtomicU64>,
569    pub engine_start: Instant,
570    active_started: Mutex<Option<Instant>>,
571    active_elapsed: Mutex<Duration>,
572    /// Total bytes across all files (for perf summary throughput).
573    pub total_bytes: u64,
574    /// The first `size=` seen per file. Only a present size is pinned, and it
575    /// is a cross-check rather than an authority: the NZB-declared size is
576    /// the bound, so one article with a wrong or missing `size=` must not
577    /// make every later article of the file fail.
578    pinned_file_sizes: Mutex<HashMap<String, u64>>,
579    /// Ensures JobFinished/JobAborted is only emitted once.
580    finished: AtomicBool,
581    /// Avoid repeatedly reporting the same all-provider outage while the
582    /// workers wait for circuit-breaker cooldown.
583    waiting_for_providers_reported: AtomicBool,
584}
585
586pub(crate) type JobContextMap = Arc<Mutex<HashMap<String, Arc<JobContext>>>>;
587
588impl JobContext {
589    fn new(
590        job: &NzbJob,
591        assembler: Arc<FileAssembler>,
592        progress_tx: mpsc::Sender<ProgressUpdate>,
593        total_articles: usize,
594    ) -> Self {
595        let nzb_filenames = job
596            .files
597            .iter()
598            .map(|f| (f.id.clone(), f.filename.clone()))
599            .collect();
600        Self {
601            job_id: job.id.clone(),
602            work_dir: job.work_dir.clone(),
603            assembler,
604            progress_tx,
605            yenc_names: Arc::new(Mutex::new(HashMap::new())),
606            nzb_filenames,
607            articles_remaining: AtomicUsize::new(total_articles),
608            articles_failed: AtomicUsize::new(0),
609            paused: AtomicBool::new(false),
610            cancelled: AtomicBool::new(false),
611            abort_reason: Mutex::new(None),
612            total_decode_us: Arc::new(AtomicU64::new(0)),
613            total_assemble_us: Arc::new(AtomicU64::new(0)),
614            total_articles_decoded: Arc::new(AtomicU64::new(0)),
615            engine_start: Instant::now(),
616            active_started: Mutex::new(Some(Instant::now())),
617            active_elapsed: Mutex::new(Duration::ZERO),
618            total_bytes: job.total_bytes,
619            pinned_file_sizes: Mutex::new(HashMap::new()),
620            finished: AtomicBool::new(false),
621            waiting_for_providers_reported: AtomicBool::new(false),
622        }
623    }
624
625    fn report_waiting_for_providers(&self, message: &str) {
626        if self
627            .waiting_for_providers_reported
628            .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
629            .is_ok()
630        {
631            try_send_progress(
632                &self.progress_tx,
633                &self.job_id,
634                ProgressUpdate::WaitingForProviders {
635                    job_id: self.job_id.clone(),
636                    message: message.to_string(),
637                },
638            );
639        }
640    }
641
642    fn report_providers_available(&self) {
643        if self
644            .waiting_for_providers_reported
645            .swap(false, Ordering::Relaxed)
646        {
647            try_send_progress(
648                &self.progress_tx,
649                &self.job_id,
650                ProgressUpdate::ProvidersAvailable {
651                    job_id: self.job_id.clone(),
652                },
653            );
654        }
655    }
656
657    fn pause_clock(&self) {
658        let mut started = self.active_started.lock();
659        if let Some(at) = started.take() {
660            *self.active_elapsed.lock() += at.elapsed();
661        }
662    }
663
664    fn resume_clock(&self) {
665        let mut started = self.active_started.lock();
666        if started.is_none() {
667            *started = Some(Instant::now());
668        }
669    }
670
671    fn active_elapsed(&self) -> Duration {
672        let elapsed = *self.active_elapsed.lock();
673        elapsed
674            + self
675                .active_started
676                .lock()
677                .map_or(Duration::ZERO, |at| at.elapsed())
678    }
679
680    /// Decrement articles_remaining. If it reaches zero, run deobfuscation
681    /// and emit the job-finished/aborted terminal update. Idempotent.
682    fn resolve_one(&self) {
683        let prev = self.articles_remaining.fetch_sub(1, Ordering::Relaxed);
684        if prev != 1 {
685            return;
686        }
687        self.emit_terminal();
688    }
689
690    /// Emit the terminal (JobFinished / JobAborted) message. Safe to call
691    /// multiple times; only the first call does anything.
692    fn emit_terminal(&self) {
693        if self.finished.swap(true, Ordering::Relaxed) {
694            return;
695        }
696
697        // Run deobfuscation before signalling completion so post-processing
698        // sees the final filenames.
699        self.deobfuscate_files();
700
701        let download_elapsed = self.engine_start.elapsed();
702        let active_elapsed = self.active_elapsed();
703        let decode_total_us = self.total_decode_us.load(Ordering::Relaxed);
704        let assemble_total_us = self.total_assemble_us.load(Ordering::Relaxed);
705        let articles_decoded = self.total_articles_decoded.load(Ordering::Relaxed);
706        let elapsed_us = download_elapsed.as_micros().max(1);
707        let throughput_mbps = (self.total_bytes as f64 / download_elapsed.as_secs_f64().max(0.001))
708            / (1024.0 * 1024.0);
709        info!(
710            job_id = %self.job_id,
711            elapsed_secs = download_elapsed.as_secs_f64(),
712            total_bytes = self.total_bytes,
713            throughput_mbps = format!("{throughput_mbps:.2}"),
714            "Download phase complete"
715        );
716        info!(
717            job_id = %self.job_id,
718            articles_decoded,
719            decode_secs = format!("{:.3}", decode_total_us as f64 / 1_000_000.0),
720            assemble_secs = format!("{:.3}", assemble_total_us as f64 / 1_000_000.0),
721            decode_pct = format!("{:.1}", decode_total_us as f64 / elapsed_us as f64 * 100.0),
722            assemble_pct = format!("{:.1}", assemble_total_us as f64 / elapsed_us as f64 * 100.0),
723            "Decode timing summary (cumulative across all workers)"
724        );
725
726        let abort_reason = self.abort_reason.lock().clone();
727        if let Some(reason) = abort_reason {
728            let failed = self.articles_failed.load(Ordering::Relaxed);
729            try_send_progress(
730                &self.progress_tx,
731                &self.job_id,
732                ProgressUpdate::JobAborted {
733                    job_id: self.job_id.clone(),
734                    reason,
735                    articles_failed: failed,
736                    download_time_secs: active_elapsed.as_secs_f64(),
737                },
738            );
739            return;
740        }
741
742        let failed = self.articles_failed.load(Ordering::Relaxed);
743        try_send_progress(
744            &self.progress_tx,
745            &self.job_id,
746            ProgressUpdate::JobFinished {
747                job_id: self.job_id.clone(),
748                success: failed == 0,
749                articles_failed: failed,
750                download_time_secs: active_elapsed.as_secs_f64(),
751            },
752        );
753    }
754
755    /// Choose the best filename between NZB subject and yEnc header per file
756    /// and rename on disk if needed. Called exactly once at job completion.
757    fn deobfuscate_files(&self) {
758        let renames = self.yenc_names.lock();
759        for (file_id, yenc_name) in renames.iter() {
760            let Some(nzb_name) = self.nzb_filenames.get(file_id) else {
761                continue;
762            };
763            if nzb_name == yenc_name {
764                continue;
765            }
766            let clean_yenc = std::path::Path::new(yenc_name.as_str())
767                .file_name()
768                .and_then(|n| n.to_str())
769                .unwrap_or(yenc_name);
770            if clean_yenc.is_empty() || nzb_name == clean_yenc {
771                continue;
772            }
773
774            let nzb_has_ext = has_known_extension(nzb_name);
775            let yenc_has_ext = has_known_extension(clean_yenc);
776
777            let (old_name, new_name) = if yenc_has_ext && !nzb_has_ext {
778                (nzb_name.as_str(), clean_yenc)
779            } else if nzb_has_ext && !yenc_has_ext {
780                continue;
781            } else if yenc_has_ext && nzb_has_ext {
782                (nzb_name.as_str(), clean_yenc)
783            } else {
784                continue;
785            };
786
787            let old_path = self.work_dir.join(old_name);
788            let new_path = self.work_dir.join(new_name);
789            if old_path.exists() && !new_path.exists() {
790                if let Err(e) = std::fs::rename(&old_path, &new_path) {
791                    warn!(
792                        job_id = %self.job_id,
793                        from = %old_name,
794                        to = %new_name,
795                        "Failed to deobfuscate file: {e}"
796                    );
797                } else {
798                    info!(
799                        job_id = %self.job_id,
800                        from = %old_name,
801                        to = %new_name,
802                        "Deobfuscated file"
803                    );
804                }
805            }
806        }
807    }
808}
809
810// ---------------------------------------------------------------------------
811// Shared work queue
812// ---------------------------------------------------------------------------
813
814/// Multi-job queue with per-server round-robin fairness and PAR2-first
815/// priority within each submission.
816///
817/// Items submitted via [`SharedWorkQueue::submit_items`] are inserted so that
818/// PAR2 index and volume files land ahead of data files (matching the prior
819/// per-job ordering), while data files land at the tail. Cross-job ordering
820/// is FIFO by submission time until a server has served a job; it then prefers
821/// another eligible job before returning to that job's backlog.
822pub(crate) struct SharedWorkQueue {
823    inner: Mutex<QueueState>,
824    notify: Notify,
825}
826
827struct QueueState {
828    items: VecDeque<WorkItem>,
829    /// Last job selected by each server. This is intentionally per server:
830    /// separate NNTP providers can make independent fair scheduling choices.
831    last_served: HashMap<String, String>,
832}
833
834impl SharedWorkQueue {
835    pub fn new() -> Self {
836        Self {
837            inner: Mutex::new(QueueState {
838                items: VecDeque::new(),
839                last_served: HashMap::new(),
840            }),
841            notify: Notify::new(),
842        }
843    }
844
845    /// Insert a batch of work items with PAR2 items ahead of data items.
846    /// Cross-batch order is preserved: PAR2 items from this batch go after
847    /// any existing items, then data items.
848    pub fn submit_items(&self, mut items: Vec<WorkItem>) {
849        items.sort_by_key(|item| par2_sort_key(&item.filename));
850        let had_items = !items.is_empty();
851        {
852            let mut q = self.inner.lock();
853            q.items.reserve(items.len());
854            for item in items {
855                q.items.push_back(item);
856            }
857        }
858        if had_items {
859            self.notify.notify_waiters();
860        }
861    }
862
863    /// Push a single item back onto the front (used when a worker is
864    /// returning an item because its job is paused or its server was just
865    /// tried for this item).
866    fn push_front(&self, item: WorkItem) {
867        self.inner.lock().items.push_front(item);
868        self.notify.notify_waiters();
869    }
870
871    /// Push a single item to the back (used after handle_article_not_available
872    /// when another server can still try it).
873    fn push_back(&self, item: WorkItem) {
874        self.inner.lock().items.push_back(item);
875        self.notify.notify_waiters();
876    }
877
878    /// `(workable, total)` for `server_id`. `workable` is the number of
879    /// items in the queue that are eligible for a worker on `server_id`;
880    /// `total` is the queue length. Used by the supervisor's starvation
881    /// diagnostic — if `workable == 0` while `total > 0`, the server has
882    /// items it can't service yet (either already tried, or waiting on a
883    /// higher-priority server).
884    ///
885    /// An item is "workable" for this server when:
886    /// - `server_id` is NOT in `item.tried_servers`, AND
887    /// - no healthy higher-priority server still needs to try it (i.e. every
888    ///   server in `higher_priority_servers` is already in `item.tried_servers`).
889    ///
890    /// Pass an empty slice when the caller has no strictly-higher-priority
891    /// peers (priority 0, single-server setups, or all peers circuit-broken).
892    pub(crate) fn workable_count_for(
893        &self,
894        server_id: &str,
895        higher_priority_servers: &[String],
896    ) -> (usize, usize) {
897        let q = self.inner.lock();
898        let total = q.items.len();
899        let workable = q
900            .items
901            .iter()
902            .filter(|i| !i.tried_servers.iter().any(|s| s == server_id))
903            .filter(|i| {
904                higher_priority_servers
905                    .iter()
906                    .all(|hp| i.tried_servers.contains(hp))
907            })
908            .count();
909        (workable, total)
910    }
911
912    /// Pop the next item that can be processed by a worker on `server_id`.
913    ///
914    /// Prefers an eligible item from a job other than the one this server last
915    /// served, then falls back to any eligible item. It also enforces server
916    /// priority: items where any healthy higher-priority server has not yet
917    /// tried the article are skipped for the backup server.
918    ///
919    /// `higher_priority_servers` is a caller-prepared list of server IDs with
920    /// strictly higher priority (lower priority number) than the caller, filtered
921    /// to only enabled + healthy servers. See `run_worker_pipelined` and
922    /// `run_worker_serial` for the canonical computation. Empty slice disables
923    /// the priority gate (priority-0 servers, single-server setups, or all
924    /// higher-priority peers circuit-broken → backup can take over).
925    ///
926    /// Returns `None` if the queue is empty or if every item is either already
927    /// tried here or pending a higher-priority server.
928    fn pop_workable(
929        &self,
930        server_id: &str,
931        higher_priority_servers: &[String],
932    ) -> Option<WorkItem> {
933        let mut state = self.inner.lock();
934        let last_served = state.last_served.get(server_id).cloned();
935        let is_eligible = |item: &WorkItem| {
936            !item.tried_servers.iter().any(|s| s == server_id)
937                && higher_priority_servers
938                    .iter()
939                    .all(|server| item.tried_servers.contains(server))
940        };
941
942        let selected = last_served
943            .as_deref()
944            .and_then(|last_job| {
945                state
946                    .items
947                    .iter()
948                    .position(|item| is_eligible(item) && item.job_id != last_job)
949            })
950            .or_else(|| state.items.iter().position(is_eligible));
951
952        if let Some(index) = selected {
953            let item = state.items.remove(index)?;
954            state
955                .last_served
956                .insert(server_id.to_string(), item.job_id.clone());
957            Some(item)
958        } else {
959            None
960        }
961    }
962
963    /// Remove all items belonging to `job_id`. Used on cancel_job / remove_job.
964    fn drain_job(&self, job_id: &str) -> Vec<WorkItem> {
965        let mut q = self.inner.lock();
966        let mut kept = VecDeque::with_capacity(q.items.len());
967        let mut drained = Vec::new();
968        while let Some(item) = q.items.pop_front() {
969            if item.job_id == job_id {
970                drained.push(item);
971            } else {
972                kept.push_back(item);
973            }
974        }
975        q.items = kept;
976        drained
977    }
978
979    /// Remove and return every queued item matching `predicate`.
980    /// Remove and return the items matching `predicate`, leaving the rest in
981    /// place and in order.
982    fn take_where(&self, mut predicate: impl FnMut(&WorkItem) -> bool) -> Vec<WorkItem> {
983        let mut q = self.inner.lock();
984        let mut taken = Vec::new();
985        let mut index = 0;
986        while index < q.items.len() {
987            if predicate(&q.items[index]) {
988                taken.push(q.items.remove(index).expect("index < len"));
989            } else {
990                index += 1;
991            }
992        }
993        taken
994    }
995
996    pub fn len(&self) -> usize {
997        self.inner.lock().items.len()
998    }
999}
1000
1001impl Default for SharedWorkQueue {
1002    fn default() -> Self {
1003        Self::new()
1004    }
1005}
1006
1007// ---------------------------------------------------------------------------
1008// Worker pool
1009// ---------------------------------------------------------------------------
1010
1011struct ActiveWorker {
1012    shutdown: Arc<AtomicBool>,
1013    /// Worker heartbeat: monotonic millis since pool creation
1014    /// (`WorkerPool::created_at`). Updated by the worker on every
1015    /// "definitive progress" event (successful decode or definitive
1016    /// failure). The supervisor reads this to detect zombie workers.
1017    last_progress: Arc<AtomicU64>,
1018    handle: JoinHandle<()>,
1019}
1020
1021/// Long-lived worker pool that services all active download jobs.
1022pub struct WorkerPool {
1023    work_queue: Arc<SharedWorkQueue>,
1024    job_contexts: JobContextMap,
1025    pub(crate) servers: Arc<Mutex<Vec<ServerConfig>>>,
1026    server_health: ServerHealthMap,
1027    bandwidth: Arc<BandwidthLimiter>,
1028    conn_tracker: Arc<ConnectionTracker>,
1029    stall_timeout: Option<Duration>,
1030    /// Reference epoch for worker heartbeats. All `last_progress` values
1031    /// store millis elapsed since this instant.
1032    created_at: Instant,
1033    /// Idle threshold above which the supervisor evicts a worker.
1034    /// Mutable so the harness (and runtime config reload) can adjust it
1035    /// without recreating the pool.
1036    max_worker_idle: Mutex<Duration>,
1037    /// Per-server "last starvation log" timestamp; rate-limits the
1038    /// "no workable items" diagnostic to once per minute per server.
1039    starvation_log: Mutex<HashMap<String, Instant>>,
1040    /// Lifetime count of worker evictions performed by the heartbeat
1041    /// watchdog. Useful for tests and observability — a non-zero value
1042    /// means at least one worker stalled long enough to be reclaimed.
1043    evictions: AtomicU64,
1044    workers: Mutex<HashMap<String, Vec<ActiveWorker>>>,
1045    shutdown: Arc<AtomicBool>,
1046    supervisor_handle: Mutex<Option<JoinHandle<()>>>,
1047}
1048
1049impl WorkerPool {
1050    pub fn new(
1051        servers: Arc<Mutex<Vec<ServerConfig>>>,
1052        bandwidth: Arc<BandwidthLimiter>,
1053        conn_tracker: Arc<ConnectionTracker>,
1054        stall_timeout_secs: u64,
1055    ) -> Arc<Self> {
1056        let stall_timeout = if stall_timeout_secs > 0 {
1057            Some(Duration::from_secs(stall_timeout_secs))
1058        } else {
1059            None
1060        };
1061        Arc::new(Self {
1062            work_queue: Arc::new(SharedWorkQueue::new()),
1063            job_contexts: Arc::new(Mutex::new(HashMap::new())),
1064            servers,
1065            server_health: Arc::new(Mutex::new(HashMap::new())),
1066            bandwidth,
1067            conn_tracker,
1068            stall_timeout,
1069            created_at: Instant::now(),
1070            max_worker_idle: Mutex::new(DEFAULT_MAX_WORKER_IDLE),
1071            starvation_log: Mutex::new(HashMap::new()),
1072            evictions: AtomicU64::new(0),
1073            workers: Mutex::new(HashMap::new()),
1074            shutdown: Arc::new(AtomicBool::new(false)),
1075            supervisor_handle: Mutex::new(None),
1076        })
1077    }
1078
1079    /// Override the worker idle eviction threshold. Tests use this to make
1080    /// the supervisor's heartbeat check converge in seconds.
1081    pub fn set_max_worker_idle(&self, d: Duration) {
1082        *self.max_worker_idle.lock() = d;
1083    }
1084
1085    /// Read current worker idle eviction threshold.
1086    pub fn max_worker_idle(&self) -> Duration {
1087        *self.max_worker_idle.lock()
1088    }
1089
1090    /// Millis elapsed since the pool was constructed. Used as the
1091    /// monotonic clock for `ActiveWorker::last_progress`.
1092    fn elapsed_ms(&self) -> u64 {
1093        self.created_at.elapsed().as_millis() as u64
1094    }
1095
1096    /// Reference `Instant` used as the epoch for `last_progress` / heartbeat
1097    /// timestamps. Exposed so the NNTP layer can share the same clock when
1098    /// ticking its socket-liveness heartbeat — values then compare directly
1099    /// against `self.elapsed_ms()` in the supervisor's idle-worker check.
1100    fn created_at(&self) -> Instant {
1101        self.created_at
1102    }
1103
1104    /// Collect server IDs with strictly higher priority (lower priority number)
1105    /// than `my_priority`, restricted to enabled + healthy (non-circuit-broken)
1106    /// servers. `my_server_id` is excluded (a server never blocks itself).
1107    ///
1108    /// Used by worker loops and the supervisor to drive the priority gate in
1109    /// [`SharedWorkQueue::pop_workable`] and [`SharedWorkQueue::workable_count_for`].
1110    /// See `sabnzbd/nzb/article.py::get_article` for the reference behaviour.
1111    fn higher_priority_servers(&self, my_priority: u8, my_server_id: &str) -> Vec<String> {
1112        let servers = self.servers.lock();
1113        let health = self.server_health.lock();
1114        servers
1115            .iter()
1116            .filter(|s| s.enabled && s.priority < my_priority && s.id != my_server_id)
1117            .filter(|s| health.get(&s.id).is_none_or(|h| h.is_available()))
1118            .map(|s| s.id.clone())
1119            .collect()
1120    }
1121
1122    /// Return a queue-facing message when no enabled provider can currently
1123    /// accept work. A circuit-broken provider is deliberately retried later,
1124    /// so this is a waiting condition rather than an article failure.
1125    fn provider_unavailable_message(&self) -> Option<&'static str> {
1126        let servers = self.servers.lock();
1127        let enabled: Vec<_> = servers.iter().filter(|server| server.enabled).collect();
1128        if enabled.is_empty() {
1129            return Some("Waiting for providers: no enabled NNTP servers are configured.");
1130        }
1131
1132        let health = self.server_health.lock();
1133        enabled
1134            .iter()
1135            .all(|server| health.get(&server.id).is_some_and(|entry| !entry.is_available()))
1136            .then_some(
1137                "Waiting for providers: every enabled server is temporarily unavailable. rustnzb will retry automatically.",
1138            )
1139    }
1140
1141    fn report_provider_outage(&self) {
1142        let Some(message) = self.provider_unavailable_message() else {
1143            return;
1144        };
1145        let contexts: Vec<_> = self.job_contexts.lock().values().cloned().collect();
1146        for ctx in contexts {
1147            ctx.report_waiting_for_providers(message);
1148        }
1149    }
1150
1151    fn report_providers_available(&self) {
1152        let contexts: Vec<_> = self.job_contexts.lock().values().cloned().collect();
1153        for ctx in contexts {
1154            ctx.report_providers_available();
1155        }
1156    }
1157
1158    /// Lifetime count of worker evictions performed by the heartbeat
1159    /// watchdog. Increases by 1 each time the supervisor reclaims a stalled
1160    /// worker. Test harnesses use this as a positive signal that the Phase
1161    /// 5 idle watchdog actually fired.
1162    pub fn eviction_count(&self) -> u64 {
1163        self.evictions.load(Ordering::Relaxed)
1164    }
1165
1166    /// Spawn workers for all currently enabled servers and start the
1167    /// supervisor task. Call once at queue-manager startup.
1168    pub fn start(self: &Arc<Self>) {
1169        self.reconcile_servers();
1170
1171        let this = Arc::clone(self);
1172        let handle = tokio::spawn(async move {
1173            this.supervisor_loop().await;
1174        });
1175        *self.supervisor_handle.lock() = Some(handle);
1176    }
1177
1178    /// Create or tear down workers to match the current server list.
1179    ///
1180    /// For each enabled server, ensures exactly `server.connections` workers
1181    /// exist. Extra workers (from a shrunk limit or disabled server) have
1182    /// their per-worker shutdown flag flipped so they exit gracefully after
1183    /// the current article.
1184    pub fn reconcile_servers(self: &Arc<Self>) {
1185        if self.shutdown.load(Ordering::Relaxed) {
1186            return;
1187        }
1188
1189        let servers_snapshot: Vec<ServerConfig> = self.servers.lock().clone();
1190        let mut workers = self.workers.lock();
1191
1192        // First pass: retire workers for servers that are gone or disabled.
1193        let mut retire: Vec<String> = Vec::new();
1194        for key in workers.keys() {
1195            let still_active = servers_snapshot.iter().any(|s| s.enabled && &s.id == key);
1196            if !still_active {
1197                retire.push(key.clone());
1198            }
1199        }
1200        for key in retire {
1201            if let Some(list) = workers.remove(&key) {
1202                for w in list {
1203                    w.shutdown.store(true, Ordering::Relaxed);
1204                    // Don't await — workers check shutdown on next loop
1205                    // iteration and exit within ~WORKER_IDLE_POLL.
1206                    drop(w.handle);
1207                }
1208            }
1209        }
1210
1211        // Second pass: spawn or shrink to match target count per enabled server.
1212        for server in &servers_snapshot {
1213            if !server.enabled {
1214                continue;
1215            }
1216            let target = (server.connections as usize).min(500);
1217            let entry = workers.entry(server.id.clone()).or_default();
1218
1219            // Shrink: signal extras to exit.
1220            while entry.len() > target {
1221                if let Some(w) = entry.pop() {
1222                    w.shutdown.store(true, Ordering::Relaxed);
1223                    drop(w.handle);
1224                }
1225            }
1226
1227            // Grow: spawn new workers with stagger.
1228            let current = entry.len();
1229            for conn_idx in current..target {
1230                let worker_shutdown = Arc::new(AtomicBool::new(false));
1231                // Initialize heartbeat to *now* so the worker has a full
1232                // grace period before its first eviction check.
1233                let last_progress = Arc::new(AtomicU64::new(self.elapsed_ms()));
1234                let pool = Arc::clone(self);
1235                let server_clone = server.clone();
1236                let ws_clone = Arc::clone(&worker_shutdown);
1237                let lp_clone = Arc::clone(&last_progress);
1238                let handle = tokio::spawn(async move {
1239                    pool_worker(pool, server_clone, conn_idx, ws_clone, lp_clone).await;
1240                });
1241                entry.push(ActiveWorker {
1242                    shutdown: worker_shutdown,
1243                    last_progress,
1244                    handle,
1245                });
1246            }
1247        }
1248    }
1249
1250    /// Register a new job context and submit its unfinished articles to the
1251    /// shared queue. Called by QueueManager::launch_download.
1252    pub(crate) fn submit_job(self: &Arc<Self>, ctx: Arc<JobContext>, items: Vec<WorkItem>) {
1253        let job_id = ctx.job_id.clone();
1254        if items.is_empty() {
1255            // Nothing to do — emit completion immediately.
1256            ctx.emit_terminal();
1257            return;
1258        }
1259        self.job_contexts.lock().insert(job_id.clone(), ctx);
1260        self.work_queue.submit_items(items);
1261        self.report_provider_outage();
1262        debug!(job_id = %job_id, queue_len = self.work_queue.len(), "Job submitted to worker pool");
1263    }
1264
1265    /// Unregister a normally completed job and close its assembler files.
1266    ///
1267    /// This must only be called after `JobFinished` is received, which means
1268    /// every article has reached a definitive result and no worker can write
1269    /// another segment for this job. Abort and cancellation paths unregister
1270    /// their contexts separately because they may still have in-flight work.
1271    pub(crate) fn release_completed_job(&self, job_id: &str) {
1272        let ctx = self.job_contexts.lock().remove(job_id);
1273        if let Some(ctx) = ctx {
1274            // Workers can briefly retain an Arc<JobContext> after resolving
1275            // the final article. Clear the assembler explicitly so those
1276            // transient references do not keep every output file open during
1277            // post-processing.
1278            ctx.assembler.clear_job(job_id);
1279        }
1280    }
1281
1282    /// Pause a job: workers stop pulling its items, and any item currently
1283    /// being held while paused is returned to the queue.
1284    pub fn pause_job(&self, job_id: &str) {
1285        if let Some(ctx) = self.job_contexts.lock().get(job_id) {
1286            ctx.pause_clock();
1287            ctx.paused.store(true, Ordering::Relaxed);
1288        }
1289    }
1290
1291    /// Resume a paused job.
1292    pub fn resume_job(&self, job_id: &str) {
1293        if let Some(ctx) = self.job_contexts.lock().get(job_id) {
1294            ctx.resume_clock();
1295            ctx.paused.store(false, Ordering::Relaxed);
1296            // Wake any workers that were idle waiting for work.
1297            self.work_queue.notify.notify_waiters();
1298        }
1299    }
1300
1301    /// Abort a job with a reason. Drains queued items, sets the abort flag,
1302    /// and emits JobAborted via the job's progress channel.
1303    pub fn abort_job(&self, job_id: &str, reason: String) -> bool {
1304        let ctx = self.job_contexts.lock().get(job_id).cloned();
1305        let Some(ctx) = ctx else {
1306            return false;
1307        };
1308        {
1309            let mut abort_reason = ctx.abort_reason.lock();
1310            if abort_reason.is_some() {
1311                return false;
1312            }
1313            *abort_reason = Some(reason);
1314        }
1315        ctx.cancelled.store(true, Ordering::Relaxed);
1316        let drained = self.work_queue.drain_job(job_id);
1317        // Decrement the remaining counter for drained items so the terminal
1318        // callback fires if nothing is in-flight for this job.
1319        for _ in drained {
1320            ctx.resolve_one();
1321        }
1322        // In-flight items still own the remaining count. They will resolve
1323        // (without writing) as their responses arrive or after a connection
1324        // failure requeues them. Only the final resolution emits JobAborted.
1325        if ctx.articles_remaining.load(Ordering::Relaxed) == 0 {
1326            ctx.emit_terminal();
1327        }
1328        true
1329    }
1330
1331    /// Cancel a job silently (no JobFinished / JobAborted emission).
1332    /// Used by `remove_job` when the user deletes a job from the queue —
1333    /// the progress receiver is about to be dropped anyway.
1334    pub fn cancel_job(&self, job_id: &str) {
1335        let ctx = self.job_contexts.lock().remove(job_id);
1336        let Some(ctx) = ctx else {
1337            return;
1338        };
1339        ctx.cancelled.store(true, Ordering::Relaxed);
1340        let _ = self.work_queue.drain_job(job_id);
1341    }
1342
1343    /// Per-tick checks that maintain pool health: idle-worker eviction,
1344    /// dead-worker reaping, worker reconciliation, and starvation diagnostics.
1345    ///
1346    /// Circuit-broken providers retain their queued articles. Workers retry
1347    /// after the provider cooldown instead of converting a transient outage
1348    /// into a persistent per-job pause.
1349    async fn supervisor_loop(self: Arc<Self>) {
1350        let mut ticker = tokio::time::interval(SUPERVISOR_INTERVAL);
1351        loop {
1352            ticker.tick().await;
1353            if self.shutdown.load(Ordering::Relaxed) {
1354                break;
1355            }
1356
1357            // ---------- 1. Heartbeat eviction ----------
1358            // Compute idle threshold + current epoch outside the workers lock.
1359            let now_ms = self.elapsed_ms();
1360            let max_idle_ms = self.max_worker_idle().as_millis() as u64;
1361
1362            // Pre-compute per-server "has workable items now" so idle backup
1363            // workers aren't evicted when there's legitimately nothing for
1364            // them to do (waiting on higher-priority servers to fail first).
1365            // Computed once per tick, outside the workers lock, to avoid
1366            // lock-ordering hazards. Matches SABnzbd's idle-thread model
1367            // (sabnzbd/downloader.py — idle threads stay connected).
1368            let server_priorities: Vec<(String, u8)> = {
1369                let srv = self.servers.lock();
1370                srv.iter()
1371                    .filter(|s| s.enabled)
1372                    .map(|s| (s.id.clone(), s.priority))
1373                    .collect()
1374            };
1375            let has_workable: HashMap<String, bool> = server_priorities
1376                .iter()
1377                .map(|(sid, prio)| {
1378                    let hp = self.higher_priority_servers(*prio, sid);
1379                    let (workable, _) = self.work_queue.workable_count_for(sid, &hp);
1380                    (sid.clone(), workable > 0)
1381                })
1382                .collect();
1383
1384            {
1385                let workers = self.workers.lock();
1386                for (server_id, list) in workers.iter() {
1387                    for (idx, w) in list.iter().enumerate() {
1388                        if w.shutdown.load(Ordering::Relaxed) {
1389                            continue;
1390                        }
1391                        let last = w.last_progress.load(Ordering::Relaxed);
1392                        let idle = now_ms.saturating_sub(last);
1393                        if idle > max_idle_ms {
1394                            // Bug 2 fix: don't evict a worker whose server
1395                            // has no workable items. It's idle because it's
1396                            // waiting for its primary/higher-priority peers
1397                            // to fail articles — not because it's zombied.
1398                            // If the server isn't in the map at all (e.g.
1399                            // disabled mid-tick), default to true (evict).
1400                            if !has_workable.get(server_id).copied().unwrap_or(true) {
1401                                continue;
1402                            }
1403                            warn!(
1404                                server = %server_id,
1405                                worker_idx = idx,
1406                                idle_ms = idle,
1407                                max_idle_ms,
1408                                "Idle-worker watchdog: evicting stalled worker"
1409                            );
1410                            w.shutdown.store(true, Ordering::Relaxed);
1411                            self.evictions.fetch_add(1, Ordering::Relaxed);
1412                        }
1413                    }
1414                }
1415            }
1416
1417            // ---------- 2. Reap finished workers and respawn ----------
1418            {
1419                let mut workers = self.workers.lock();
1420                for list in workers.values_mut() {
1421                    list.retain(|w| !w.handle.is_finished());
1422                }
1423            }
1424            // Reconcile fills any gaps left by reaped workers.
1425            self.reconcile_servers();
1426
1427            // ---------- 3. Resolve articles no longer waiting on a required server ----------
1428            self.resolve_items_not_waiting_on_required_providers();
1429
1430            // ---------- 4. Starvation diagnostic ----------
1431            // For each enabled server: if the queue has items but none are
1432            // workable for this server, log once per minute. "Not workable"
1433            // can mean either (a) every item has already been tried here, or
1434            // (b) every item is still waiting on a higher-priority server
1435            // (backup server legitimately idle — not a bug).
1436            let now_instant = Instant::now();
1437            for (sid, prio) in &server_priorities {
1438                let hp = self.higher_priority_servers(*prio, sid);
1439                let (workable, total) = self.work_queue.workable_count_for(sid, &hp);
1440                if workable == 0 && total > 0 {
1441                    let mut log = self.starvation_log.lock();
1442                    let should_log = log
1443                        .get(sid)
1444                        .map(|t| now_instant.duration_since(*t) >= Duration::from_secs(60))
1445                        .unwrap_or(true);
1446                    if should_log {
1447                        log.insert(sid.clone(), now_instant);
1448                        let reason = if hp.is_empty() {
1449                            "every item has been tried here already"
1450                        } else {
1451                            "every item has been tried here, or is waiting for a higher-priority server"
1452                        };
1453                        info!(
1454                            server = %sid,
1455                            total_items = total,
1456                            higher_priority_servers = hp.len(),
1457                            "Queue has items but none are workable for this server ({reason})"
1458                        );
1459                    }
1460                }
1461            }
1462        }
1463    }
1464
1465    /// Fail queued articles that every required provider has already
1466    /// answered definitively.
1467    ///
1468    /// An item is normally resolved by the worker that records the last
1469    /// outcome. When an optional server becomes unavailable after the other
1470    /// servers have answered, the item is left in the queue waiting for that
1471    /// server, whose workers no longer pull work. Since the optional server is
1472    /// no longer required, resolve such items here.
1473    fn resolve_items_not_waiting_on_required_providers(&self) {
1474        // Nothing can become resolvable unless an optional server is down:
1475        // with every enabled server required, an item resolves only when a
1476        // worker records the last server's outcome. Skip the queue walk.
1477        {
1478            let servers = self.servers.lock();
1479            let health = self.server_health.lock();
1480            let optional_down = servers.iter().any(|server| {
1481                server.enabled
1482                    && server.optional
1483                    && health.get(&server.id).is_some_and(|h| !h.is_available())
1484            });
1485            if !optional_down {
1486                return;
1487            }
1488        }
1489        let taken = {
1490            let servers = self.servers.lock();
1491            let health = self.server_health.lock();
1492            self.work_queue.take_where(|item| {
1493                !item.provider_outcomes.is_empty()
1494                    && all_enabled_providers_definitive(&servers, &health, &item.provider_outcomes)
1495            })
1496        };
1497        for item in taken {
1498            let ctx = self.job_contexts.lock().get(&item.job_id).cloned();
1499            let Some(ctx) = ctx else {
1500                continue;
1501            };
1502            if ctx.cancelled.load(Ordering::Relaxed) {
1503                ctx.resolve_one();
1504                continue;
1505            }
1506            let last_server = item.tried_servers.last().cloned().unwrap_or_default();
1507            let last_kind = item
1508                .provider_outcomes
1509                .get(&last_server)
1510                .copied()
1511                .unwrap_or(crate::article_failure::ArticleFailureKind::NotFound);
1512            fail_article_everywhere(
1513                &item,
1514                &last_server,
1515                &ctx,
1516                "supervisor",
1517                last_kind,
1518                "Article unavailable on every required server (optional servers unavailable)",
1519            );
1520        }
1521    }
1522
1523    /// Shut down all workers gracefully. In-flight articles finish first.
1524    pub async fn shutdown(self: &Arc<Self>) {
1525        self.shutdown.store(true, Ordering::Relaxed);
1526        let handles: Vec<JoinHandle<()>> = {
1527            let mut workers = self.workers.lock();
1528            let mut out = Vec::new();
1529            for (_id, list) in workers.drain() {
1530                for w in list {
1531                    w.shutdown.store(true, Ordering::Relaxed);
1532                    out.push(w.handle);
1533                }
1534            }
1535            out
1536        };
1537        // Notify workers so any parked on notify.notified() wake up.
1538        self.work_queue.notify.notify_waiters();
1539
1540        await_worker_shutdown(handles, Duration::from_secs(10)).await;
1541
1542        if let Some(h) = self.supervisor_handle.lock().take() {
1543            h.abort();
1544        }
1545    }
1546
1547    pub fn conn_tracker(&self) -> &Arc<ConnectionTracker> {
1548        &self.conn_tracker
1549    }
1550
1551    /// Whether this job still has an active context in the pool.
1552    pub fn has_job(&self, job_id: &str) -> bool {
1553        self.job_contexts.lock().contains_key(job_id)
1554    }
1555
1556    /// Active (unpaused) download time of a registered job, in seconds.
1557    pub fn job_active_secs(&self, job_id: &str) -> Option<f64> {
1558        self.job_contexts
1559            .lock()
1560            .get(job_id)
1561            .map(|ctx| ctx.active_elapsed().as_secs_f64())
1562    }
1563}
1564
1565async fn await_worker_shutdown(mut handles: Vec<JoinHandle<()>>, timeout: Duration) {
1566    let deadline = tokio::time::Instant::now() + timeout;
1567    for handle in &mut handles {
1568        if tokio::time::timeout_at(deadline, &mut *handle)
1569            .await
1570            .is_err()
1571        {
1572            break;
1573        }
1574    }
1575    for handle in handles {
1576        if !handle.is_finished() {
1577            handle.abort();
1578        }
1579    }
1580}
1581
1582// ---------------------------------------------------------------------------
1583// Worker body
1584// ---------------------------------------------------------------------------
1585
1586/// Single pool worker. Owns an NNTP connection to `primary_server` and pulls
1587/// items from the shared queue until `worker_shutdown` is flipped (server
1588/// reconciled away, limit shrunk) or the pool shuts down.
1589///
1590/// The worker acquires its connection slot (a semaphore permit from the
1591/// per-server pool) **before** the first connect attempt and holds it for
1592/// the entire lifetime of the worker, across every reconnect. This bounds
1593/// concurrent connections to `server.connections` by construction.
1594async fn pool_worker(
1595    pool: Arc<WorkerPool>,
1596    primary_server: ServerConfig,
1597    conn_idx: usize,
1598    worker_shutdown: Arc<AtomicBool>,
1599    last_progress: Arc<AtomicU64>,
1600) {
1601    let worker_id = format!("{}#{}", primary_server.id, conn_idx);
1602
1603    // Stagger worker startup to avoid thundering herd of connections.
1604    if conn_idx > 0 {
1605        let stagger = WORKER_RAMP_DELAY * conn_idx as u32;
1606        tokio::time::sleep(stagger).await;
1607    }
1608
1609    let should_exit = |worker_shutdown: &Arc<AtomicBool>, pool: &Arc<WorkerPool>| {
1610        worker_shutdown.load(Ordering::Relaxed) || pool.shutdown.load(Ordering::Relaxed)
1611    };
1612
1613    // Acquire the slot up-front. If the server isn't registered or its limit
1614    // is zero, the worker has nothing to do — exit.
1615    let mut conn_slot = match pool.conn_tracker.acquire(&primary_server.id).await {
1616        Some(slot) => slot,
1617        None => {
1618            info!(
1619                worker = %worker_id,
1620                server = %primary_server.name,
1621                "No connection slot available (server removed or limit=0); worker exiting"
1622            );
1623            return;
1624        }
1625    };
1626
1627    'reconnect: loop {
1628        if should_exit(&worker_shutdown, &pool) {
1629            return;
1630        }
1631
1632        // If the limit was shrunk under us, the semaphore will have been
1633        // replaced. Drop our (now-orphaned) slot and exit cleanly.
1634        match pool.conn_tracker.slot_status(&conn_slot) {
1635            SlotStatus::Current => {}
1636            SlotStatus::PoolReplaced => {
1637                info!(
1638                    worker = %worker_id,
1639                    server = %primary_server.name,
1640                    reason = "pool_replaced",
1641                    "Connection slot is stale (connection limit changed); worker exiting"
1642                );
1643                return;
1644            }
1645            SlotStatus::ServerRemoved => {
1646                info!(
1647                    worker = %worker_id,
1648                    server = %primary_server.name,
1649                    reason = "server_removed",
1650                    "Connection slot is stale (server removed from tracker); worker exiting"
1651                );
1652                return;
1653            }
1654        }
1655
1656        // Check circuit breaker before connecting. Compute an owned bool
1657        // so we don't hold the MutexGuard across an await point.
1658        let circuit_broken = {
1659            let health = pool.server_health.lock();
1660            health
1661                .get(&primary_server.id)
1662                .is_some_and(|h| !h.is_available())
1663        };
1664        if circuit_broken {
1665            pool.report_provider_outage();
1666            tokio::time::sleep(WORKER_IDLE_POLL).await;
1667            continue 'reconnect;
1668        }
1669
1670        info!(
1671            worker = %worker_id,
1672            server = %primary_server.name,
1673            host = %primary_server.host,
1674            port = primary_server.port,
1675            ssl = primary_server.ssl,
1676            conn_idx,
1677            "Worker starting — connecting to primary server"
1678        );
1679
1680        let mut conn = NntpConnection::new(worker_id.clone());
1681        // Attach socket-liveness heartbeat BEFORE connect so every byte
1682        // received — from the welcome banner onward — counts as progress.
1683        // This is the fix for false-eviction of slow-but-working workers:
1684        // previously `last_progress` only advanced on full article decode,
1685        // so a worker receiving a 50-second article looked idle for the
1686        // entire fetch. Now any line read from the socket keeps it alive.
1687        // Matches SABnzbd's `nw.timeout` model (newswrapper.py:315).
1688        conn.set_io_heartbeat(last_progress.clone(), pool.created_at());
1689        if let Err(e) = connect_with_retry(
1690            &mut conn,
1691            &primary_server,
1692            &worker_id,
1693            &pool.server_health,
1694            &pool.servers,
1695        )
1696        .await
1697        {
1698            warn!(
1699                worker = %worker_id,
1700                server = %primary_server.name,
1701                host = %primary_server.host,
1702                "Worker FAILED to connect after all retries: {e}"
1703            );
1704            if should_exit(&worker_shutdown, &pool) {
1705                return;
1706            }
1707            pool.report_provider_outage();
1708            tokio::time::sleep(RECONNECT_DELAY).await;
1709            continue 'reconnect;
1710        }
1711        pool.report_providers_available();
1712        let pipe_depth = primary_server.pipelining.max(1);
1713        let active_conns = pool.conn_tracker.total();
1714        info!(
1715            worker = %worker_id,
1716            server = %primary_server.name,
1717            host = %primary_server.host,
1718            pipelining = pipe_depth,
1719            total_nntp_connections = active_conns,
1720            "Worker connected and ready"
1721        );
1722
1723        // NOTE: do not tick heartbeat here. Reconnects are *not* progress
1724        // — counting them masks the zombie cycle (worker reconnects forever
1725        // without ever decoding an article). The heartbeat is initialised
1726        // to spawn time in `reconcile_servers`, which gives every worker a
1727        // full grace period to first-connect and process its first article.
1728        let reconnect_needed = if pipe_depth <= 1 {
1729            run_worker_serial(
1730                &pool,
1731                &primary_server,
1732                &worker_id,
1733                &worker_shutdown,
1734                &mut conn,
1735                &mut conn_slot,
1736                &last_progress,
1737            )
1738            .await
1739        } else {
1740            run_worker_pipelined(
1741                &pool,
1742                &primary_server,
1743                &worker_id,
1744                pipe_depth,
1745                &worker_shutdown,
1746                &mut conn,
1747                &mut conn_slot,
1748                &last_progress,
1749            )
1750            .await
1751        };
1752
1753        let _ = conn.quit().await;
1754        match reconnect_needed {
1755            WorkerExit::Reconnect => {
1756                // Loop back to the top and reconnect — slot is preserved.
1757                continue 'reconnect;
1758            }
1759            WorkerExit::Exit => {
1760                // Slot drops here when conn_slot goes out of scope.
1761                return;
1762            }
1763        }
1764    }
1765}
1766
1767enum WorkerExit {
1768    /// Exit the worker function entirely (server retired or pool shutdown).
1769    Exit,
1770    /// Reconnect and keep pulling work (transient connection loss).
1771    Reconnect,
1772}
1773
1774/// Wait for work, with early exit on shutdown / server retirement.
1775/// Returns `Some(item, ctx)` when a processable item is available, or `None`
1776/// if the worker should exit.
1777///
1778/// `higher_priority_servers` gates which items this server is allowed to take
1779/// — see `pop_workable` for the priority model.
1780async fn next_work_item(
1781    pool: &Arc<WorkerPool>,
1782    server_id: &str,
1783    higher_priority_servers: &[String],
1784    worker_shutdown: &Arc<AtomicBool>,
1785) -> Option<(WorkItem, Arc<JobContext>)> {
1786    loop {
1787        if worker_shutdown.load(Ordering::Relaxed) || pool.shutdown.load(Ordering::Relaxed) {
1788            return None;
1789        }
1790
1791        if let Some(item) = pool
1792            .work_queue
1793            .pop_workable(server_id, higher_priority_servers)
1794        {
1795            // Look up the job context. If the job is gone or cancelled, drop
1796            // the item and keep going.
1797            let ctx = pool.job_contexts.lock().get(&item.job_id).cloned();
1798            let Some(ctx) = ctx else {
1799                continue;
1800            };
1801            if ctx.cancelled.load(Ordering::Relaxed) {
1802                ctx.resolve_one();
1803                continue;
1804            }
1805            // Respect per-job pause: return the item and wait.
1806            if ctx.paused.load(Ordering::Relaxed) {
1807                pool.work_queue.push_back(item);
1808                tokio::time::sleep(WORKER_IDLE_POLL).await;
1809                continue;
1810            }
1811            return Some((item, ctx));
1812        }
1813
1814        // Queue empty (or nothing workable for this server) — wait with a
1815        // timeout so we still notice shutdown and new work alike.
1816        let notified = pool.work_queue.notify.notified();
1817        tokio::select! {
1818            _ = notified => {}
1819            _ = tokio::time::sleep(WORKER_IDLE_POLL) => {}
1820        }
1821    }
1822}
1823
1824async fn run_worker_serial(
1825    pool: &Arc<WorkerPool>,
1826    primary_server: &ServerConfig,
1827    worker_id: &str,
1828    worker_shutdown: &Arc<AtomicBool>,
1829    conn: &mut NntpConnection,
1830    conn_slot: &mut ConnectionSlot,
1831    last_progress: &Arc<AtomicU64>,
1832) -> WorkerExit {
1833    let mut consecutive_errors: u32 = 0;
1834
1835    loop {
1836        // Server runtime checks.
1837        let server_disabled = pool
1838            .servers
1839            .lock()
1840            .iter()
1841            .find(|s| s.id == primary_server.id)
1842            .is_none_or(|s| !s.enabled);
1843        if server_disabled {
1844            info!(
1845                worker = %worker_id,
1846                server = %primary_server.name,
1847                "Server disabled, worker exiting"
1848            );
1849            return WorkerExit::Exit;
1850        }
1851        {
1852            let health = pool.server_health.lock();
1853            if let Some(h) = health.get(&primary_server.id)
1854                && !h.is_available()
1855            {
1856                info!(
1857                    worker = %worker_id,
1858                    server = %primary_server.name,
1859                    reason = h.reason.as_deref().unwrap_or("unknown"),
1860                    "Server circuit-broken, worker reconnecting after cooldown"
1861                );
1862                return WorkerExit::Reconnect;
1863            }
1864        }
1865
1866        // Snapshot of healthy servers with strictly higher priority (lower
1867        // priority number). Used as the priority gate in pop_workable:
1868        // items waiting for a higher-priority server won't be dispatched
1869        // to this worker. Recomputed each loop iteration so runtime
1870        // priority / health changes are picked up. See SABnzbd
1871        // `Article.get_article` for the reference behaviour.
1872        let higher_priority_servers =
1873            pool.higher_priority_servers(primary_server.priority, &primary_server.id);
1874
1875        let Some((mut item, ctx)) = next_work_item(
1876            pool,
1877            &primary_server.id,
1878            &higher_priority_servers,
1879            worker_shutdown,
1880        )
1881        .await
1882        else {
1883            return WorkerExit::Exit;
1884        };
1885
1886        let result = {
1887            let _activity = conn_slot.activity();
1888            let fetch_fut = fetch_article_with_retry(
1889                conn,
1890                &item,
1891                &ctx.assembler,
1892                &ctx.cancelled,
1893                primary_server,
1894                worker_id,
1895            );
1896            if let Some(timeout) = pool.stall_timeout {
1897                match tokio::time::timeout(timeout, fetch_fut).await {
1898                    Ok(r) => r,
1899                    Err(_) => {
1900                        warn!(
1901                            worker = %worker_id,
1902                            server = %primary_server.name,
1903                            article = %item.message_id,
1904                            "Connection stalled — no response within {}s, reconnecting",
1905                            timeout.as_secs()
1906                        );
1907                        if retry_or_fail_over(
1908                            item,
1909                            primary_server,
1910                            pool,
1911                            &ctx,
1912                            worker_id,
1913                            crate::article_failure::ArticleFailureKind::Timeout,
1914                            "connection stalled",
1915                        ) {
1916                            last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
1917                        }
1918                        return WorkerExit::Reconnect;
1919                    }
1920                }
1921            } else {
1922                fetch_fut.await
1923            }
1924        };
1925
1926        match result {
1927            Ok(process_result) => {
1928                consecutive_errors = 0;
1929                ctx.total_decode_us
1930                    .fetch_add(process_result.decode_us, Ordering::Relaxed);
1931                ctx.total_assemble_us
1932                    .fetch_add(process_result.assemble_us, Ordering::Relaxed);
1933                ctx.total_articles_decoded.fetch_add(1, Ordering::Relaxed);
1934                if let Some(ref yname) = process_result.yenc_filename {
1935                    ctx.yenc_names
1936                        .lock()
1937                        .entry(item.file_id.clone())
1938                        .or_insert_with(|| crate::util::normalize_nfc(yname));
1939                }
1940                if let Some(n) = std::num::NonZeroU32::new(process_result.decoded_bytes as u32) {
1941                    let _ = pool.bandwidth.acquire_download(n).await;
1942                }
1943                try_send_progress(
1944                    &ctx.progress_tx,
1945                    &item.job_id,
1946                    ProgressUpdate::ArticleComplete {
1947                        job_id: item.job_id.clone(),
1948                        file_id: item.file_id.clone(),
1949                        segment_number: item.segment_number,
1950                        decoded_bytes: process_result.decoded_bytes,
1951                        file_complete: process_result.file_complete,
1952                        server_id: Some(primary_server.id.clone()),
1953                        yenc_filename: process_result.yenc_filename.clone(),
1954                    },
1955                );
1956                ctx.resolve_one();
1957                // Heartbeat: definitive forward progress.
1958                last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
1959            }
1960            Err(ArticleError::ArticleNotFound) => {
1961                if handle_article_not_available(
1962                    &mut item,
1963                    primary_server,
1964                    &pool.servers,
1965                    &pool.server_health,
1966                    &ctx,
1967                    &pool.work_queue,
1968                    worker_id,
1969                    crate::article_failure::ArticleFailureKind::NotFound,
1970                    "Article not found on any server",
1971                ) {
1972                    last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
1973                }
1974            }
1975            Err(ArticleError::ConnectionLost(msg)) => {
1976                increment_counter("nntp.transient_reconnects");
1977                consecutive_errors += 1;
1978                warn!(
1979                    worker = %worker_id,
1980                    server = %primary_server.name,
1981                    host = %primary_server.host,
1982                    consecutive_errors,
1983                    max_reconnects = MAX_RECONNECT_ATTEMPTS,
1984                    article = %item.message_id,
1985                    "Connection lost: {msg}"
1986                );
1987                pool.work_queue.push_front(item);
1988                if consecutive_errors > MAX_RECONNECT_ATTEMPTS {
1989                    warn!(
1990                        worker = %worker_id,
1991                        server = %primary_server.name,
1992                        host = %primary_server.host,
1993                        consecutive_errors,
1994                        "Too many consecutive errors — worker reconnecting"
1995                    );
1996                    return WorkerExit::Reconnect;
1997                }
1998                return WorkerExit::Reconnect;
1999            }
2000            Err(ArticleError::ProviderUnavailable { kind, message }) => {
2001                increment_counter("nntp.transient_reconnects");
2002                let is_auth = matches!(
2003                    kind,
2004                    crate::article_failure::ArticleFailureKind::AuthFailed
2005                        | crate::article_failure::ArticleFailureKind::PermissionDenied
2006                );
2007                warn!(
2008                    job_id = %item.job_id,
2009                    file_id = %item.file_id,
2010                    segment_number = item.segment_number,
2011                    message_id = %item.message_id,
2012                    server_id = %primary_server.id,
2013                    worker_id = %worker_id,
2014                    original_failure_kind = kind.as_str(),
2015                    "Provider unavailable during article fetch: {message}"
2016                );
2017                pool.server_health
2018                    .lock()
2019                    .entry(primary_server.id.clone())
2020                    .or_default()
2021                    .record_failure(is_auth, &message);
2022                pool.report_provider_outage();
2023                pool.work_queue.push_front(item);
2024                return WorkerExit::Reconnect;
2025            }
2026            Err(ArticleError::ServerError { kind, message }) => {
2027                if retry_or_fail_over(item, primary_server, pool, &ctx, worker_id, kind, &message) {
2028                    last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2029                }
2030                if conn.state != ConnectionState::Ready {
2031                    return WorkerExit::Reconnect;
2032                }
2033            }
2034            Err(ArticleError::DecodeError(msg)) => {
2035                if handle_article_not_available(
2036                    &mut item,
2037                    primary_server,
2038                    &pool.servers,
2039                    &pool.server_health,
2040                    &ctx,
2041                    &pool.work_queue,
2042                    worker_id,
2043                    crate::article_failure::ArticleFailureKind::DecodeError,
2044                    &format!("Decode error: {msg}"),
2045                ) {
2046                    last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2047                }
2048            }
2049            Err(ArticleError::Cancelled) => {
2050                // Mirror the pipelined path: an aborted job's in-flight
2051                // article resolves without being written or reported.
2052                ctx.resolve_one();
2053            }
2054            Err(ArticleError::AssemblyError(msg)) => {
2055                error!(job_id = %item.job_id, file_id = %item.file_id, segment_number = item.segment_number, message_id = %item.message_id, server_id = %primary_server.id, worker_id = %worker_id, original_failure_kind = "decode_error", terminal_failure_kind = "decode_error", "Assembly error: {msg}");
2056                try_send_progress(
2057                    &ctx.progress_tx,
2058                    &item.job_id,
2059                    ProgressUpdate::ArticleFailed {
2060                        job_id: item.job_id.clone(),
2061                        file_id: item.file_id.clone(),
2062                        segment_number: item.segment_number,
2063                        failure: crate::article_failure::ArticleFailure::decode_error(
2064                            &primary_server.id,
2065                            format!("Assembly error: {msg}"),
2066                        ),
2067                    },
2068                );
2069                ctx.articles_failed.fetch_add(1, Ordering::Relaxed);
2070                ctx.resolve_one();
2071                last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2072            }
2073        }
2074    }
2075}
2076
2077#[allow(clippy::too_many_arguments)]
2078async fn run_worker_pipelined(
2079    pool: &Arc<WorkerPool>,
2080    primary_server: &ServerConfig,
2081    worker_id: &str,
2082    pipe_depth: u8,
2083    worker_shutdown: &Arc<AtomicBool>,
2084    conn: &mut NntpConnection,
2085    conn_slot: &mut ConnectionSlot,
2086    last_progress: &Arc<AtomicU64>,
2087) -> WorkerExit {
2088    let mut pipeline = Pipeline::new(pipe_depth);
2089    let mut in_flight_items: HashMap<u64, WorkItem> = HashMap::new();
2090    let mut next_tag: u64 = 0;
2091    let mut consecutive_errors: u32 = 0;
2092
2093    // Perf metrics
2094    let mut perf_articles: u64 = 0;
2095    let mut perf_bytes: u64 = 0;
2096    let mut perf_queue_lock_us: u64 = 0;
2097    let mut perf_receive_us: u64 = 0;
2098    let mut perf_decode_us: u64 = 0;
2099    let mut perf_assemble_us: u64 = 0;
2100    let mut perf_bandwidth_us: u64 = 0;
2101    let mut perf_yield_us: u64 = 0;
2102    let mut perf_flush_us: u64 = 0;
2103    let mut perf_last_log = Instant::now();
2104    const PERF_LOG_INTERVAL: Duration = Duration::from_secs(10);
2105
2106    loop {
2107        if worker_shutdown.load(Ordering::Relaxed) || pool.shutdown.load(Ordering::Relaxed) {
2108            requeue_all(&mut in_flight_items, &pool.work_queue);
2109            return WorkerExit::Exit;
2110        }
2111
2112        // Server runtime checks.
2113        let server_disabled = pool
2114            .servers
2115            .lock()
2116            .iter()
2117            .find(|s| s.id == primary_server.id)
2118            .is_none_or(|s| !s.enabled);
2119        if server_disabled {
2120            info!(
2121                worker = %worker_id,
2122                server = %primary_server.name,
2123                "Server disabled, worker exiting"
2124            );
2125            requeue_all(&mut in_flight_items, &pool.work_queue);
2126            return WorkerExit::Exit;
2127        }
2128        {
2129            let health = pool.server_health.lock();
2130            if let Some(h) = health.get(&primary_server.id)
2131                && !h.is_available()
2132            {
2133                info!(
2134                    worker = %worker_id,
2135                    server = %primary_server.name,
2136                    reason = h.reason.as_deref().unwrap_or("unknown"),
2137                    "Server circuit-broken, worker reconnecting after cooldown"
2138                );
2139                requeue_all(&mut in_flight_items, &pool.work_queue);
2140                return WorkerExit::Reconnect;
2141            }
2142        }
2143
2144        // Snapshot of healthy servers with strictly higher priority. Gates
2145        // which items the pipeline-fill and wait-for-work paths are allowed
2146        // to take (see `pop_workable`). Recomputed each loop iteration so
2147        // runtime priority / health changes are picked up.
2148        let higher_priority_servers =
2149            pool.higher_priority_servers(primary_server.priority, &primary_server.id);
2150
2151        // Fill the pipeline.
2152        while pipeline.pending_count() + pipeline.in_flight_count() < pipe_depth as usize {
2153            let lock_t = Instant::now();
2154            let item = pool
2155                .work_queue
2156                .pop_workable(&primary_server.id, &higher_priority_servers);
2157            perf_queue_lock_us += lock_t.elapsed().as_micros() as u64;
2158            let Some(item) = item else {
2159                break;
2160            };
2161            // Look up ctx to respect pause/cancel.
2162            let ctx = pool.job_contexts.lock().get(&item.job_id).cloned();
2163            let Some(ctx) = ctx else {
2164                continue;
2165            };
2166            if ctx.cancelled.load(Ordering::Relaxed) {
2167                ctx.resolve_one();
2168                continue;
2169            }
2170            if ctx.paused.load(Ordering::Relaxed) {
2171                pool.work_queue.push_back(item);
2172                break;
2173            }
2174            let tag = next_tag;
2175            next_tag += 1;
2176            pipeline.submit(item.message_id.clone(), tag);
2177            in_flight_items.insert(tag, item);
2178        }
2179
2180        // If nothing is queued and nothing in flight, wait for work.
2181        if pipeline.is_empty() && in_flight_items.is_empty() {
2182            let Some((first_item, ctx)) = next_work_item(
2183                pool,
2184                &primary_server.id,
2185                &higher_priority_servers,
2186                worker_shutdown,
2187            )
2188            .await
2189            else {
2190                return WorkerExit::Exit;
2191            };
2192            let _ = ctx; // ctx is validated in next_work_item
2193            let tag = next_tag;
2194            next_tag += 1;
2195            pipeline.submit(first_item.message_id.clone(), tag);
2196            in_flight_items.insert(tag, first_item);
2197        }
2198
2199        let _activity = conn_slot.activity();
2200        let flush_t = Instant::now();
2201        if let Err(e) = pipeline.flush_sends(conn).await {
2202            warn!(
2203                worker = %worker_id,
2204                server = %primary_server.name,
2205                host = %primary_server.host,
2206                error = %e,
2207                in_flight = in_flight_items.len(),
2208                "Pipeline send error — re-queuing all in-flight items"
2209            );
2210            requeue_all(&mut in_flight_items, &pool.work_queue);
2211            consecutive_errors += 1;
2212            if consecutive_errors > MAX_RECONNECT_ATTEMPTS {
2213                warn!(
2214                    worker = %worker_id,
2215                    server = %primary_server.name,
2216                    consecutive_errors,
2217                    "Too many pipeline errors — worker reconnecting"
2218                );
2219                return WorkerExit::Reconnect;
2220            }
2221            tokio::time::sleep(RECONNECT_DELAY).await;
2222            return WorkerExit::Reconnect;
2223        }
2224        perf_flush_us += flush_t.elapsed().as_micros() as u64;
2225
2226        // Read one response.
2227        let recv_t = Instant::now();
2228        trace!(
2229            worker = %worker_id,
2230            in_flight = in_flight_items.len(),
2231            stall_timeout_secs = pool.stall_timeout.map(|d| d.as_secs()).unwrap_or(0),
2232            "Pipeline: awaiting response"
2233        );
2234        let result = if let Some(timeout) = pool.stall_timeout {
2235            match tokio::time::timeout(timeout, pipeline.receive_one(conn)).await {
2236                Ok(r) => r,
2237                Err(_) => {
2238                    let elapsed_ms = recv_t.elapsed().as_millis();
2239                    warn!(
2240                        worker = %worker_id,
2241                        server = %primary_server.name,
2242                        elapsed_ms,
2243                        in_flight = in_flight_items.len(),
2244                        "Connection stalled — no response within {}s, reconnecting",
2245                        timeout.as_secs()
2246                    );
2247                    requeue_all(&mut in_flight_items, &pool.work_queue);
2248                    return WorkerExit::Reconnect;
2249                }
2250            }
2251        } else {
2252            pipeline.receive_one(conn).await
2253        };
2254        perf_receive_us += recv_t.elapsed().as_micros() as u64;
2255
2256        match result {
2257            Ok(Some(pipe_result)) => {
2258                let Some(mut item) = in_flight_items.remove(&pipe_result.request.tag) else {
2259                    continue;
2260                };
2261                // Look up the ctx for this item's job (may have been cancelled).
2262                let ctx = pool.job_contexts.lock().get(&item.job_id).cloned();
2263                let Some(ctx) = ctx else {
2264                    continue;
2265                };
2266                if ctx.cancelled.load(Ordering::Relaxed) {
2267                    ctx.resolve_one();
2268                    continue;
2269                }
2270
2271                match pipe_result.result {
2272                    Ok(response) => {
2273                        consecutive_errors = 0;
2274                        let raw_data = response.data.unwrap_or_default();
2275                        let yield_t = Instant::now();
2276                        tokio::task::yield_now().await;
2277                        perf_yield_us += yield_t.elapsed().as_micros() as u64;
2278                        let decode_result = decode_and_assemble(
2279                            &item,
2280                            &raw_data,
2281                            &ctx.assembler,
2282                            Some(&ctx.pinned_file_sizes),
2283                        );
2284                        // Return the buffer to the connection's pool so the
2285                        // next article's fetch reuses it instead of
2286                        // allocating fresh.
2287                        conn.release_body_buffer(raw_data);
2288                        match decode_result {
2289                            Ok(process_result) => {
2290                                perf_decode_us += process_result.decode_us;
2291                                perf_assemble_us += process_result.assemble_us;
2292                                perf_bytes += process_result.decoded_bytes;
2293                                perf_articles += 1;
2294                                ctx.total_decode_us
2295                                    .fetch_add(process_result.decode_us, Ordering::Relaxed);
2296                                ctx.total_assemble_us
2297                                    .fetch_add(process_result.assemble_us, Ordering::Relaxed);
2298                                ctx.total_articles_decoded.fetch_add(1, Ordering::Relaxed);
2299                                if let Some(ref yname) = process_result.yenc_filename {
2300                                    ctx.yenc_names
2301                                        .lock()
2302                                        .entry(item.file_id.clone())
2303                                        .or_insert_with(|| crate::util::normalize_nfc(yname));
2304                                }
2305                                let bw_t = Instant::now();
2306                                if let Some(n) =
2307                                    std::num::NonZeroU32::new(process_result.decoded_bytes as u32)
2308                                {
2309                                    let _ = pool.bandwidth.acquire_download(n).await;
2310                                }
2311                                perf_bandwidth_us += bw_t.elapsed().as_micros() as u64;
2312                                try_send_progress(
2313                                    &ctx.progress_tx,
2314                                    &item.job_id,
2315                                    ProgressUpdate::ArticleComplete {
2316                                        job_id: item.job_id.clone(),
2317                                        file_id: item.file_id.clone(),
2318                                        segment_number: item.segment_number,
2319                                        decoded_bytes: process_result.decoded_bytes,
2320                                        file_complete: process_result.file_complete,
2321                                        server_id: Some(primary_server.id.clone()),
2322                                        yenc_filename: process_result.yenc_filename.clone(),
2323                                    },
2324                                );
2325                                ctx.resolve_one();
2326                                // Heartbeat: definitive forward progress.
2327                                last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2328
2329                                if perf_last_log.elapsed() >= PERF_LOG_INTERVAL {
2330                                    let elapsed = perf_last_log.elapsed().as_secs_f64();
2331                                    let mbps = perf_bytes as f64 / elapsed / (1024.0 * 1024.0);
2332                                    info!(
2333                                        worker = %worker_id,
2334                                        articles = perf_articles,
2335                                        throughput_mbps = format!("{mbps:.1}"),
2336                                        recv_ms = perf_receive_us / 1000,
2337                                        decode_ms = perf_decode_us / 1000,
2338                                        assemble_ms = perf_assemble_us / 1000,
2339                                        queue_lock_ms = perf_queue_lock_us / 1000,
2340                                        flush_ms = perf_flush_us / 1000,
2341                                        yield_ms = perf_yield_us / 1000,
2342                                        bw_wait_ms = perf_bandwidth_us / 1000,
2343                                        "Worker perf summary"
2344                                    );
2345                                    perf_articles = 0;
2346                                    perf_bytes = 0;
2347                                    perf_queue_lock_us = 0;
2348                                    perf_receive_us = 0;
2349                                    perf_decode_us = 0;
2350                                    perf_assemble_us = 0;
2351                                    perf_bandwidth_us = 0;
2352                                    perf_yield_us = 0;
2353                                    perf_flush_us = 0;
2354                                    perf_last_log = Instant::now();
2355                                }
2356                            }
2357                            Err(ArticleError::DecodeError(msg)) => {
2358                                if handle_article_not_available(
2359                                    &mut item,
2360                                    primary_server,
2361                                    &pool.servers,
2362                                    &pool.server_health,
2363                                    &ctx,
2364                                    &pool.work_queue,
2365                                    worker_id,
2366                                    crate::article_failure::ArticleFailureKind::DecodeError,
2367                                    &format!("Decode error: {msg}"),
2368                                ) {
2369                                    last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2370                                }
2371                            }
2372                            Err(ArticleError::AssemblyError(msg)) => {
2373                                error!(job_id = %item.job_id, file_id = %item.file_id, segment_number = item.segment_number, message_id = %item.message_id, server_id = %primary_server.id, worker_id = %worker_id, original_failure_kind = "decode_error", terminal_failure_kind = "decode_error", "Assembly error: {msg}");
2374                                try_send_progress(
2375                                    &ctx.progress_tx,
2376                                    &item.job_id,
2377                                    ProgressUpdate::ArticleFailed {
2378                                        job_id: item.job_id.clone(),
2379                                        file_id: item.file_id.clone(),
2380                                        segment_number: item.segment_number,
2381                                        failure:
2382                                            crate::article_failure::ArticleFailure::decode_error(
2383                                                &primary_server.id,
2384                                                format!("Assembly error: {msg}"),
2385                                            ),
2386                                    },
2387                                );
2388                                ctx.articles_failed.fetch_add(1, Ordering::Relaxed);
2389                                ctx.resolve_one();
2390                                last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2391                            }
2392                            Err(_) => {}
2393                        }
2394                    }
2395                    Err(
2396                        NntpError::ArticleNotFound(_)
2397                        | NntpError::NoSuchGroup(_)
2398                        | NntpError::NoArticleSelected(_),
2399                    ) => {
2400                        // 430 and 411/412/420/423 are article-level "not
2401                        // here" answers; the pipelined connection stays usable.
2402                        if handle_article_not_available(
2403                            &mut item,
2404                            primary_server,
2405                            &pool.servers,
2406                            &pool.server_health,
2407                            &ctx,
2408                            &pool.work_queue,
2409                            worker_id,
2410                            crate::article_failure::ArticleFailureKind::NotFound,
2411                            "Article not found on any server",
2412                        ) {
2413                            last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2414                        }
2415                    }
2416                    Err(NntpError::Connection(_) | NntpError::Io(_)) => {
2417                        increment_counter("nntp.transient_reconnects");
2418                        warn!(
2419                            job_id = %item.job_id,
2420                            file_id = %item.file_id,
2421                            segment_number = item.segment_number,
2422                            worker = %worker_id,
2423                            server = %primary_server.name,
2424                            host = %primary_server.host,
2425                            article = %item.message_id,
2426                            in_flight = in_flight_items.len(),
2427                            consecutive_errors,
2428                            "Pipeline: connection lost during receive — re-queuing all"
2429                        );
2430                        pool.work_queue.push_front(item);
2431                        requeue_all(&mut in_flight_items, &pool.work_queue);
2432                        consecutive_errors += 1;
2433                        if consecutive_errors > MAX_RECONNECT_ATTEMPTS {
2434                            return WorkerExit::Reconnect;
2435                        }
2436                        tokio::time::sleep(RECONNECT_DELAY).await;
2437                        return WorkerExit::Reconnect;
2438                    }
2439                    Err(e) => {
2440                        increment_counter("nntp.transient_reconnects");
2441                        let failure = crate::article_failure::ArticleFailure::from_nntp(
2442                            &e,
2443                            &primary_server.id,
2444                        );
2445                        warn!(
2446                            job_id = %item.job_id,
2447                            file_id = %item.file_id,
2448                            segment_number = item.segment_number,
2449                            message_id = %item.message_id,
2450                            server_id = %primary_server.id,
2451                            worker_id = %worker_id,
2452                            original_failure_kind = failure.kind.as_str(),
2453                            attempt = item.tries_on_current + 1,
2454                            "Transient pipeline error — re-queuing in-flight work and reconnecting: {e}"
2455                        );
2456                        // Only provider-level failures count against the
2457                        // circuit breaker, as in the serial path. Article-level
2458                        // answers (protocol errors such as 451, timeouts) are
2459                        // capped per server by the outage-try limit below;
2460                        // counting them let a few bad articles pause the whole
2461                        // server.
2462                        if counts_against_circuit_breaker(&e) {
2463                            let is_auth = matches!(
2464                                failure.kind,
2465                                crate::article_failure::ArticleFailureKind::AuthFailed
2466                                    | crate::article_failure::ArticleFailureKind::PermissionDenied
2467                            );
2468                            pool.server_health
2469                                .lock()
2470                                .entry(primary_server.id.clone())
2471                                .or_default()
2472                                .record_failure(is_auth, &e.to_string());
2473                            pool.report_provider_outage();
2474                        }
2475                        // A server-wide outage (502, 480, 403) says nothing
2476                        // about the article, so it is re-queued without
2477                        // spending a try: the circuit breaker decides when the
2478                        // server is unusable. A timeout or a dropped
2479                        // connection on a server that is otherwise serving
2480                        // articles may be this one article, so those are
2481                        // capped — otherwise one poison article is retried at
2482                        // the head of the queue forever.
2483                        let server_wide = matches!(
2484                            failure.kind,
2485                            crate::article_failure::ArticleFailureKind::ServerDown
2486                                | crate::article_failure::ArticleFailureKind::AuthFailed
2487                                | crate::article_failure::ArticleFailureKind::PermissionDenied
2488                        );
2489                        let article_outage = matches!(
2490                            failure.kind,
2491                            crate::article_failure::ArticleFailureKind::Timeout
2492                                | crate::article_failure::ArticleFailureKind::ConnectionClosed
2493                                | crate::article_failure::ArticleFailureKind::Other
2494                        );
2495                        if server_wide {
2496                            pool.work_queue.push_front(item);
2497                        } else if article_outage {
2498                            item.outage_tries += 1;
2499                            if item.outage_tries < MAX_OUTAGE_TRIES_PER_SERVER {
2500                                pool.work_queue.push_front(item);
2501                            } else if retry_or_fail_over(
2502                                item,
2503                                primary_server,
2504                                pool,
2505                                &ctx,
2506                                worker_id,
2507                                failure.kind,
2508                                &e.to_string(),
2509                            ) {
2510                                last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2511                            }
2512                        } else if retry_or_fail_over(
2513                            item,
2514                            primary_server,
2515                            pool,
2516                            &ctx,
2517                            worker_id,
2518                            failure.kind,
2519                            &e.to_string(),
2520                        ) {
2521                            last_progress.store(pool.elapsed_ms(), Ordering::Relaxed);
2522                        }
2523                        requeue_all(&mut in_flight_items, &pool.work_queue);
2524                        return WorkerExit::Reconnect;
2525                    }
2526                }
2527            }
2528            Ok(None) => {
2529                // No in-flight requests — loop will fill more.
2530            }
2531            Err(e) => {
2532                warn!(
2533                    worker = %worker_id,
2534                    server = %primary_server.name,
2535                    host = %primary_server.host,
2536                    error = %e,
2537                    in_flight = in_flight_items.len(),
2538                    consecutive_errors,
2539                    "Pipeline receive error"
2540                );
2541                requeue_all(&mut in_flight_items, &pool.work_queue);
2542                consecutive_errors += 1;
2543                if consecutive_errors > MAX_RECONNECT_ATTEMPTS {
2544                    return WorkerExit::Reconnect;
2545                }
2546                tokio::time::sleep(RECONNECT_DELAY).await;
2547                return WorkerExit::Reconnect;
2548            }
2549        }
2550    }
2551}
2552
2553// ---------------------------------------------------------------------------
2554// Connection with retry
2555// ---------------------------------------------------------------------------
2556
2557async fn connect_with_retry(
2558    conn: &mut NntpConnection,
2559    server: &ServerConfig,
2560    worker_id: &str,
2561    server_health: &ServerHealthMap,
2562    all_servers: &Arc<Mutex<Vec<ServerConfig>>>,
2563) -> Result<(), String> {
2564    for attempt in 1..=MAX_RECONNECT_ATTEMPTS {
2565        {
2566            let health = server_health.lock();
2567            if let Some(h) = health.get(&server.id)
2568                && !h.is_available()
2569            {
2570                return Err(format!(
2571                    "Server circuit-broken: {}",
2572                    h.reason.as_deref().unwrap_or("unknown")
2573                ));
2574            }
2575        }
2576
2577        let current_config = all_servers
2578            .lock()
2579            .iter()
2580            .find(|s| s.id == server.id)
2581            .cloned()
2582            .unwrap_or_else(|| server.clone());
2583
2584        info!(
2585            worker = %worker_id,
2586            server = %current_config.name,
2587            host = %current_config.host,
2588            port = current_config.port,
2589            attempt,
2590            max_attempts = MAX_RECONNECT_ATTEMPTS,
2591            "Connect attempt starting"
2592        );
2593        match conn.connect(&current_config).await {
2594            Ok(()) => {
2595                info!(
2596                    worker = %worker_id,
2597                    server = %current_config.name,
2598                    host = %current_config.host,
2599                    attempt,
2600                    "Connect attempt succeeded"
2601                );
2602                server_health
2603                    .lock()
2604                    .entry(server.id.clone())
2605                    .or_default()
2606                    .record_success();
2607                return Ok(());
2608            }
2609            Err(e) => {
2610                let is_auth = matches!(e, NntpError::Auth(_) | NntpError::ServiceUnavailable(_));
2611                {
2612                    let mut health = server_health.lock();
2613                    let entry = health.entry(server.id.clone()).or_default();
2614                    entry.record_failure(is_auth, &e.to_string());
2615                    if !entry.is_available() {
2616                        warn!(
2617                            worker = %worker_id,
2618                            server = %current_config.name,
2619                            host = %current_config.host,
2620                            error = %e,
2621                            cooldown_secs = if is_auth { AUTH_FAILURE_COOLDOWN.as_secs() } else { TRANSIENT_FAILURE_COOLDOWN.as_secs() },
2622                            "Server circuit-broken — stopping all connection attempts"
2623                        );
2624                        return Err(format!("Server circuit-broken: {e}"));
2625                    }
2626                }
2627
2628                warn!(
2629                    worker = %worker_id,
2630                    server = %current_config.name,
2631                    host = %current_config.host,
2632                    attempt,
2633                    max_attempts = MAX_RECONNECT_ATTEMPTS,
2634                    error = %e,
2635                    is_auth,
2636                    "Connect attempt FAILED: {e}"
2637                );
2638
2639                if is_auth {
2640                    return Err(format!("Auth/permission failure: {e}"));
2641                }
2642
2643                if attempt < MAX_RECONNECT_ATTEMPTS {
2644                    info!(
2645                        worker = %worker_id,
2646                        server = %current_config.name,
2647                        delay_secs = RECONNECT_DELAY.as_secs(),
2648                        "Waiting before retry"
2649                    );
2650                    tokio::time::sleep(RECONNECT_DELAY).await;
2651                    *conn = NntpConnection::new(worker_id.to_string());
2652                } else {
2653                    return Err(format!(
2654                        "All {MAX_RECONNECT_ATTEMPTS} connect attempts failed: {e}"
2655                    ));
2656                }
2657            }
2658        }
2659    }
2660    Err("Connect retry loop exited unexpectedly".into())
2661}
2662
2663// ---------------------------------------------------------------------------
2664// Helpers: re-queue, not-available routing, par2 sort key
2665// ---------------------------------------------------------------------------
2666
2667/// Handle an article that's not available on this server (not found, decode
2668/// error, etc.): mark the server as tried and either requeue or mark failed.
2669///
2670/// `kind` lets the failure be classified — if every server has been tried
2671/// and the failure was per-server (NotFound, ServerDown, …), we promote it
2672/// to a definitive `NotFound` for the hopeless tracker. DecodeError stays
2673/// classified as DecodeError because it's typically not server-specific.
2674#[allow(clippy::too_many_arguments)]
2675fn handle_article_not_available(
2676    item: &mut WorkItem,
2677    primary_server: &ServerConfig,
2678    all_servers: &Arc<Mutex<Vec<ServerConfig>>>,
2679    server_health: &ServerHealthMap,
2680    ctx: &Arc<JobContext>,
2681    work_queue: &Arc<SharedWorkQueue>,
2682    worker_id: &str,
2683    kind: crate::article_failure::ArticleFailureKind,
2684    error_msg: &str,
2685) -> bool {
2686    item.provider_outcomes
2687        .insert(primary_server.id.clone(), kind);
2688    if !item.tried_servers.contains(&primary_server.id) {
2689        item.tried_servers.push(primary_server.id.clone());
2690    }
2691    item.tries_on_current = 0;
2692    item.outage_tries = 0;
2693
2694    let all_definitive = all_enabled_providers_definitive(
2695        &all_servers.lock(),
2696        &server_health.lock(),
2697        &item.provider_outcomes,
2698    );
2699
2700    debug!(
2701        article = %item.message_id,
2702        server = %primary_server.id,
2703        kind = kind.as_str(),
2704        tried_count = item.tried_servers.len(),
2705        all_definitive,
2706        "Article returned error on this server"
2707    );
2708
2709    // (debug log immediately below was added for observability)
2710    if all_definitive {
2711        fail_article_everywhere(item, &primary_server.id, ctx, worker_id, kind, error_msg);
2712        true
2713    } else {
2714        // push_FRONT (not push_back): put the failed item at the front of the
2715        // queue so the next eligible server picks it up IMMEDIATELY instead
2716        // of queueing behind thousands of fresh items. Same-server workers
2717        // rotate the item back via pop_workable's existing skip-and-push_back
2718        // logic. This transforms a retention-dead NZB from "11+ minutes per
2719        // article's full-server cascade" into "fractions of a second per
2720        // cascade" — the dominant failure mode for hung downloads.
2721        work_queue.push_front(item.clone());
2722        false
2723    }
2724}
2725
2726/// Handle a non-connection error for `item` on `primary_server`: retry it on
2727/// the same server up to [`MAX_TRIES_PER_SERVER`] times, then record the
2728/// error as this server's definitive outcome so the article fails over to
2729/// the next server (or fails once every server has an outcome). Without the
2730/// cap such errors were re-queued for the same server forever.
2731///
2732/// Returns `true` when the article was resolved as failed.
2733fn retry_or_fail_over(
2734    mut item: WorkItem,
2735    primary_server: &ServerConfig,
2736    pool: &Arc<WorkerPool>,
2737    ctx: &Arc<JobContext>,
2738    worker_id: &str,
2739    kind: crate::article_failure::ArticleFailureKind,
2740    message: &str,
2741) -> bool {
2742    item.tries_on_current += 1;
2743    if item.tries_on_current < MAX_TRIES_PER_SERVER {
2744        pool.work_queue.push_front(item);
2745        return false;
2746    }
2747    handle_article_not_available(
2748        &mut item,
2749        primary_server,
2750        &pool.servers,
2751        &pool.server_health,
2752        ctx,
2753        &pool.work_queue,
2754        worker_id,
2755        kind,
2756        &format!(
2757            "Article failed on every server; last error after {MAX_TRIES_PER_SERVER} attempts: {message}"
2758        ),
2759    )
2760}
2761
2762/// Resolve an article as failed: every required provider has given a
2763/// definitive outcome. `server_id` is the provider that answered last.
2764fn fail_article_everywhere(
2765    item: &WorkItem,
2766    server_id: &str,
2767    ctx: &Arc<JobContext>,
2768    worker_id: &str,
2769    kind: crate::article_failure::ArticleFailureKind,
2770    error_msg: &str,
2771) {
2772    let mut provider_outcomes = item
2773        .provider_outcomes
2774        .iter()
2775        .map(|(server, outcome)| format!("{server}={}", outcome.as_str()))
2776        .collect::<Vec<_>>();
2777    provider_outcomes.sort_unstable();
2778    let outcomes = provider_outcomes.join(",");
2779    let all_not_found = item
2780        .provider_outcomes
2781        .values()
2782        .all(|outcome| *outcome == crate::article_failure::ArticleFailureKind::NotFound);
2783    let final_kind = if all_not_found {
2784        crate::article_failure::ArticleFailureKind::NotFound
2785    } else {
2786        crate::article_failure::ArticleFailureKind::DecodeError
2787    };
2788    warn!(
2789        job_id = %item.job_id,
2790        file_id = %item.file_id,
2791        segment_number = item.segment_number,
2792        message_id = %item.message_id,
2793        server_id = %server_id,
2794        worker_id = %worker_id,
2795        original_failure_kind = kind.as_str(),
2796        terminal_failure_kind = final_kind.as_str(),
2797        attempt = item.tried_servers.len(),
2798        provider_outcomes = %outcomes,
2799        "{error_msg}"
2800    );
2801    let final_failure = if final_kind == crate::article_failure::ArticleFailureKind::DecodeError {
2802        crate::article_failure::ArticleFailure::decode_error(
2803            server_id,
2804            format!("{error_msg}; provider outcomes: {outcomes}"),
2805        )
2806    } else {
2807        increment_counter("articles.explicit_global_absence");
2808        crate::article_failure::ArticleFailure::not_found_anywhere(server_id, outcomes)
2809    };
2810    try_send_progress(
2811        &ctx.progress_tx,
2812        &item.job_id,
2813        ProgressUpdate::ArticleFailed {
2814            job_id: item.job_id.clone(),
2815            file_id: item.file_id.clone(),
2816            segment_number: item.segment_number,
2817            failure: final_failure,
2818        },
2819    );
2820    ctx.articles_failed.fetch_add(1, Ordering::Relaxed);
2821    ctx.resolve_one();
2822}
2823
2824/// Whether every provider whose answer is required has given a definitive
2825/// outcome for an article.
2826///
2827/// Every enabled server is required, except an `optional` server that is
2828/// currently unavailable (circuit open, e.g. it cannot be reached). A
2829/// required server that is down still blocks the decision: an outage is not
2830/// evidence that the article is missing. An optional server is a best-effort
2831/// extra, so it must not hold articles hostage while it is down.
2832fn all_enabled_providers_definitive(
2833    servers: &[ServerConfig],
2834    health: &HashMap<String, ServerHealth>,
2835    outcomes: &HashMap<String, crate::article_failure::ArticleFailureKind>,
2836) -> bool {
2837    servers
2838        .iter()
2839        .filter(|server| server.enabled)
2840        .filter(|server| {
2841            !(server.optional && health.get(&server.id).is_some_and(|h| !h.is_available()))
2842        })
2843        .all(|server| outcomes.contains_key(&server.id))
2844}
2845
2846/// Whether a fetch error says the provider itself is unhealthy, and so should
2847/// count against its circuit breaker: connection/TLS, auth, permission, 502
2848/// and timeouts. A server that accepts the connection and then black-holes
2849/// the read is as unhealthy as one that refuses it. Article-level answers
2850/// never count.
2851fn counts_against_circuit_breaker(e: &NntpError) -> bool {
2852    matches!(
2853        e,
2854        NntpError::Connection(_)
2855            | NntpError::Io(_)
2856            | NntpError::Tls(_)
2857            | NntpError::Auth(_)
2858            | NntpError::AuthRequired(_)
2859            | NntpError::PermissionDenied(_)
2860            | NntpError::ServiceUnavailable(_)
2861            | NntpError::Timeout(_)
2862    )
2863}
2864
2865/// Re-queue all in-flight items back to the work queue (on connection loss).
2866fn requeue_all(in_flight: &mut HashMap<u64, WorkItem>, work_queue: &Arc<SharedWorkQueue>) {
2867    let items: Vec<WorkItem> = in_flight.drain().map(|(_, item)| item).collect();
2868    for item in items {
2869        work_queue.push_front(item);
2870    }
2871}
2872
2873/// Sort key for work-queue prioritisation of PAR2 files. Index files (0)
2874/// first, then volume files (1), then data files (2).
2875fn par2_sort_key(filename: &str) -> u8 {
2876    let lower = filename.to_lowercase();
2877    if lower.ends_with(".par2") {
2878        if lower.contains(".vol") { 1 } else { 0 }
2879    } else {
2880        2
2881    }
2882}
2883
2884pub fn has_known_extension(name: &str) -> bool {
2885    let lower = name.to_lowercase();
2886    if let Some(dot_pos) = lower.rfind('.') {
2887        let ext = &lower[dot_pos + 1..];
2888        matches!(
2889            ext,
2890            "rar"
2891                | "r00"
2892                | "r01"
2893                | "r02"
2894                | "r03"
2895                | "r04"
2896                | "r05"
2897                | "zip"
2898                | "7z"
2899                | "gz"
2900                | "bz2"
2901                | "xz"
2902                | "tar"
2903                | "mkv"
2904                | "mp4"
2905                | "avi"
2906                | "wmv"
2907                | "ts"
2908                | "m4v"
2909                | "mov"
2910                | "mpg"
2911                | "mpeg"
2912                | "mp3"
2913                | "flac"
2914                | "ogg"
2915                | "m4a"
2916                | "aac"
2917                | "wav"
2918                | "srt"
2919                | "sub"
2920                | "idx"
2921                | "ass"
2922                | "ssa"
2923                | "sup"
2924                | "nfo"
2925                | "jpg"
2926                | "jpeg"
2927                | "png"
2928                | "gif"
2929                | "bmp"
2930                | "par2"
2931                | "001"
2932                | "002"
2933                | "003"
2934                | "004"
2935                | "005"
2936        )
2937    } else {
2938        false
2939    }
2940}
2941
2942// ---------------------------------------------------------------------------
2943// Public helper used by queue_manager: build work items + context for a job
2944// ---------------------------------------------------------------------------
2945
2946/// Build the WorkItems for a job's unfinished articles and an initialised
2947/// JobContext. Called by QueueManager before [`WorkerPool::submit_job`].
2948pub(crate) fn build_job_submission(
2949    job: &NzbJob,
2950    progress_tx: mpsc::Sender<ProgressUpdate>,
2951    enabled_server_ids: &[String],
2952) -> (Arc<JobContext>, Vec<WorkItem>) {
2953    let assembler = Arc::new(FileAssembler::new());
2954    for file in &job.files {
2955        let output_path = job.work_dir.join(&file.filename);
2956        let completed_segments: Vec<u32> = file
2957            .articles
2958            .iter()
2959            .filter(|article| article.downloaded)
2960            .map(|article| article.segment_number)
2961            .collect();
2962        // Register the declared segment numbers rather than assuming 1..=N:
2963        // an NZB may skip or repeat numbers.
2964        let segment_numbers: Vec<u32> = file
2965            .articles
2966            .iter()
2967            .map(|article| article.segment_number)
2968            .collect();
2969        // Only trust the NZB size when every segment reported one. The parser
2970        // turns a missing or unparsable `bytes` attribute into 0, so a file
2971        // whose segments omit it sums to 0 (or to an understatement) and
2972        // every article would then be rejected as past the declared size.
2973        // With no bound, the write check falls back to the article's size=.
2974        let declared_bytes = file
2975            .articles
2976            .iter()
2977            .all(|article| article.bytes > 0)
2978            .then_some(file.bytes);
2979        if let Err(e) = assembler.register_file_with_segment_numbers(
2980            &job.id,
2981            &file.id,
2982            output_path,
2983            &segment_numbers,
2984            &completed_segments,
2985            declared_bytes,
2986        ) {
2987            error!(file = %file.filename, "Failed to register file for assembly: {e}");
2988        }
2989    }
2990
2991    // `tried_servers` on a persisted article is only ever written when the
2992    // article reached a terminal `ArticleFailed`. That is only still terminal
2993    // if every server enabled now was one of them: a server added or
2994    // re-enabled since the failure has never been asked, so the article must
2995    // be fetched again rather than skipped.
2996    let is_previously_failed = |article: &nzb_nntp::Article| {
2997        !article.downloaded
2998            && !enabled_server_ids.is_empty()
2999            && enabled_server_ids
3000                .iter()
3001                .all(|server| article.tried_servers.iter().any(|tried| tried == server))
3002    };
3003    let previously_failed = job
3004        .files
3005        .iter()
3006        .flat_map(|file| file.articles.iter())
3007        .filter(|article| is_previously_failed(article))
3008        .count();
3009
3010    let work_items: Vec<WorkItem> = job
3011        .files
3012        .iter()
3013        .flat_map(|file| {
3014            file.articles
3015                .iter()
3016                .filter(|article| !article.downloaded && !is_previously_failed(article))
3017                .map(move |article| WorkItem {
3018                    job_id: job.id.clone(),
3019                    file_id: file.id.clone(),
3020                    filename: file.filename.clone(),
3021                    message_id: article.message_id.clone(),
3022                    segment_number: article.segment_number,
3023                    tried_servers: article.tried_servers.clone(),
3024                    provider_outcomes: HashMap::new(),
3025                    tries_on_current: 0,
3026                    outage_tries: 0,
3027                })
3028        })
3029        .collect();
3030
3031    let total_remaining = work_items.len();
3032    let ctx = Arc::new(JobContext::new(
3033        job,
3034        assembler,
3035        progress_tx,
3036        total_remaining,
3037    ));
3038    ctx.articles_failed
3039        .store(previously_failed, Ordering::Relaxed);
3040    (ctx, work_items)
3041}
3042
3043// ---------------------------------------------------------------------------
3044// Article fetch with per-server retry
3045// ---------------------------------------------------------------------------
3046
3047async fn fetch_article_with_retry(
3048    conn: &mut NntpConnection,
3049    item: &WorkItem,
3050    assembler: &FileAssembler,
3051    cancelled: &AtomicBool,
3052    _server: &ServerConfig,
3053    worker_id: &str,
3054) -> Result<ProcessResult, ArticleError> {
3055    // Retries of article-level errors are counted per item by the caller
3056    // (`WorkItem::tries_on_current`), so each call makes a single attempt.
3057    let attempt = item.tries_on_current + 1;
3058    let fetch_start = Instant::now();
3059    match conn.fetch_article(&item.message_id).await {
3060        Ok(response) => {
3061            let fetch_us = fetch_start.elapsed().as_micros();
3062            let raw_data = response.data.unwrap_or_default();
3063            debug!(
3064                worker = %worker_id,
3065                article = %item.message_id,
3066                raw_bytes = raw_data.len(),
3067                fetch_us,
3068                "NNTP fetch complete"
3069            );
3070            // The job may have been aborted while this fetch was in
3071            // flight. Like the pipelined path, drop the response rather
3072            // than writing it into an aborted job's files.
3073            if cancelled.load(Ordering::Relaxed) {
3074                conn.release_body_buffer(raw_data);
3075                return Err(ArticleError::Cancelled);
3076            }
3077            let result = decode_and_assemble(item, &raw_data, assembler, None);
3078            // Return the buffer to the connection's pool so the next
3079            // article's fetch reuses it instead of allocating fresh.
3080            conn.release_body_buffer(raw_data);
3081            result
3082        }
3083        Err(NntpError::ArticleNotFound(_)) => {
3084            debug!(
3085                worker = %worker_id,
3086                article = %item.message_id,
3087                "Article not found (430) — will try next server"
3088            );
3089            Err(ArticleError::ArticleNotFound)
3090        }
3091        Err(e @ (NntpError::NoSuchGroup(_) | NntpError::NoArticleSelected(_))) => {
3092            // 411/412/420/423: an article-level "not here" answer. Retrying on
3093            // this server cannot change it.
3094            debug!(
3095                worker = %worker_id,
3096                article = %item.message_id,
3097                error = %e,
3098                "Article unavailable on this server — will try next server"
3099            );
3100            Err(ArticleError::ArticleNotFound)
3101        }
3102        Err(e @ (NntpError::Connection(_) | NntpError::Io(_))) => {
3103            warn!(
3104                worker = %worker_id,
3105                article = %item.message_id,
3106                attempt,
3107                error = %e,
3108                conn_state = ?conn.state,
3109                "Connection/IO error during fetch — connection lost"
3110            );
3111            Err(ArticleError::ConnectionLost(format!(
3112                "Connection error on attempt {attempt}: {e}"
3113            )))
3114        }
3115        Err(e @ NntpError::Tls(_)) => {
3116            warn!(
3117                worker = %worker_id,
3118                article = %item.message_id,
3119                attempt,
3120                error = %e,
3121                "TLS error during fetch — connection lost"
3122            );
3123            Err(ArticleError::ConnectionLost(format!("TLS error: {e}")))
3124        }
3125        Err(e @ NntpError::ServiceUnavailable(_)) => {
3126            warn!(
3127                worker = %worker_id,
3128                article = %item.message_id,
3129                attempt,
3130                error = %e,
3131                "Service unavailable (502) during article fetch — likely rate limited or blocked"
3132            );
3133            Err(ArticleError::ProviderUnavailable {
3134                kind: crate::article_failure::ArticleFailureKind::ServerDown,
3135                message: e.to_string(),
3136            })
3137        }
3138        Err(e @ (NntpError::AuthRequired(_) | NntpError::Auth(_))) => {
3139            warn!(
3140                worker = %worker_id,
3141                article = %item.message_id,
3142                attempt,
3143                error = %e,
3144                "Auth required (480) during article fetch — session expired or rate limited"
3145            );
3146            Err(ArticleError::ProviderUnavailable {
3147                kind: crate::article_failure::ArticleFailureKind::AuthFailed,
3148                message: e.to_string(),
3149            })
3150        }
3151        Err(e @ NntpError::PermissionDenied(_)) => Err(ArticleError::ProviderUnavailable {
3152            kind: crate::article_failure::ArticleFailureKind::PermissionDenied,
3153            message: e.to_string(),
3154        }),
3155        Err(e) => {
3156            // Protocol / unexpected response / timeout and the like. The caller
3157            // retries up to MAX_TRIES_PER_SERVER times on this server, then
3158            // records a definitive outcome so the article fails over.
3159            warn!(
3160                worker = %worker_id,
3161                article = %item.message_id,
3162                attempt,
3163                max_tries = MAX_TRIES_PER_SERVER,
3164                error = %e,
3165                "Article fetch error on this server"
3166            );
3167            Err(ArticleError::ServerError {
3168                kind: crate::article_failure::ArticleFailure::from_nntp(&e, "").kind,
3169                message: e.to_string(),
3170            })
3171        }
3172    }
3173}
3174
3175// ---------------------------------------------------------------------------
3176// Article processing
3177// ---------------------------------------------------------------------------
3178
3179#[derive(Debug)]
3180struct ProcessResult {
3181    decoded_bytes: u64,
3182    file_complete: bool,
3183    yenc_filename: Option<String>,
3184    decode_us: u64,
3185    assemble_us: u64,
3186}
3187
3188#[derive(Debug, thiserror::Error)]
3189enum ArticleError {
3190    #[error("Article not found on server")]
3191    ArticleNotFound,
3192    #[error("Connection lost: {0}")]
3193    ConnectionLost(String),
3194    /// A non-connection error for this article on this server (protocol
3195    /// error, unexpected response, timeout). Retried a bounded number of
3196    /// times per server, then treated as a definitive outcome.
3197    #[error("Server error ({kind:?}): {message}")]
3198    ServerError {
3199        kind: crate::article_failure::ArticleFailureKind,
3200        message: String,
3201    },
3202    #[error("Provider unavailable ({kind:?}): {message}")]
3203    ProviderUnavailable {
3204        kind: crate::article_failure::ArticleFailureKind,
3205        message: String,
3206    },
3207    #[error("Decode error: {0}")]
3208    DecodeError(String),
3209    #[error("Assembly error: {0}")]
3210    AssemblyError(String),
3211    /// The job was cancelled while the fetch was in flight; the article was
3212    /// not written.
3213    #[error("Job cancelled")]
3214    Cancelled,
3215}
3216
3217/// Choose the file offset a decoded article is written at, rejecting headers
3218/// that would place it outside the file.
3219///
3220/// The offset comes from `=ypart begin=` and is used directly as the `pwrite`
3221/// position. The bound is the size the NZB declared for the file
3222/// (`declared_bytes`), not the `size=` the article itself claims: a forged
3223/// `size=2e12 begin=1e12` would otherwise create a terabyte sparse file. The
3224/// first `size=` seen for a file is pinned (`pinned_size`) and later articles
3225/// must agree with it. A multi-segment file with no bound at all — no NZB
3226/// size and no `size=` — is rejected, as is a `part_end` that does not
3227/// describe exactly the decoded bytes. A missing `=ypart` on a later segment
3228/// would overwrite the start of the file and is rejected too.
3229fn validated_write_offset(
3230    segment_number: u32,
3231    decoded: &nzb_decode::YencDecodeResult,
3232    data_len: u64,
3233    declared_bytes: Option<u64>,
3234    pinned_size: Option<u64>,
3235) -> Result<u64, String> {
3236    let file_size = decoded.file_size;
3237    let part_begin = decoded.part_begin;
3238    let part_end = decoded.part_end;
3239    if file_size != pinned_size {
3240        return Err(format!(
3241            "article size {file_size:?} does not match the size {pinned_size:?} established for this file"
3242        ));
3243    }
3244    let bound = match declared_bytes.or(pinned_size) {
3245        Some(size) => Some(size),
3246        None if segment_number <= 1 => None,
3247        None => {
3248            return Err(format!(
3249                "no declared file size for multi-segment file (segment {segment_number})"
3250            ));
3251        }
3252    };
3253    let begin = match part_begin {
3254        Some(begin) => begin,
3255        // A single-part article (no =ypart) can only be the first segment.
3256        None if segment_number <= 1 => 0,
3257        None => {
3258            return Err(format!(
3259                "no =ypart header on segment {segment_number} of a multi-part file"
3260            ));
3261        }
3262    };
3263    if let Some(size) = bound {
3264        if size == 0 {
3265            return Err("declared file size is 0".to_string());
3266        }
3267        if begin >= size {
3268            return Err(format!(
3269                "=ypart begin {begin} is past the declared file size {size}"
3270            ));
3271        }
3272        match begin.checked_add(data_len) {
3273            Some(end) if end <= size => {
3274                if let Some(part_end) = part_end
3275                    && part_end != end
3276                {
3277                    return Err(format!(
3278                        "=ypart end {part_end} does not match the decoded bytes ending at {end}"
3279                    ));
3280                }
3281            }
3282            _ => {
3283                return Err(format!(
3284                    "part range begin={begin} len={data_len} exceeds declared file size {size}"
3285                ));
3286            }
3287        }
3288    }
3289    Ok(begin)
3290}
3291
3292fn decode_and_assemble(
3293    item: &WorkItem,
3294    raw_data: &[u8],
3295    assembler: &FileAssembler,
3296    pinned_file_sizes: Option<&Mutex<HashMap<String, u64>>>,
3297) -> Result<ProcessResult, ArticleError> {
3298    let decode_start = Instant::now();
3299    let decoded = decode_yenc(raw_data).map_err(|e| {
3300        ArticleError::DecodeError(format!(
3301            "yEnc decode failed for {} seg {}: {e}",
3302            item.filename, item.segment_number
3303        ))
3304    })?;
3305    let decode_us = decode_start.elapsed().as_micros();
3306
3307    // Remember the first size= seen for this file, but only a real one: an
3308    // article that omits size= must not become the pin. The pin is a
3309    // cross-check. The NZB-declared size is what bounds the write, so when it
3310    // is known a disagreement is logged and the article is still written.
3311    let declared = assembler.declared_bytes(&item.job_id, &item.file_id);
3312    let pinned_size = match (pinned_file_sizes, decoded.file_size) {
3313        (Some(sizes), Some(size)) => {
3314            let mut sizes = sizes.lock();
3315            match sizes.entry(item.file_id.clone()) {
3316                std::collections::hash_map::Entry::Occupied(existing) => Some(*existing.get()),
3317                std::collections::hash_map::Entry::Vacant(slot) => {
3318                    slot.insert(size);
3319                    Some(size)
3320                }
3321            }
3322        }
3323        (Some(sizes), None) => sizes.lock().get(&item.file_id).copied(),
3324        (None, size) => size,
3325    };
3326    if declared.is_some() && decoded.file_size != pinned_size {
3327        warn!(
3328            file_id = %item.file_id,
3329            segment_number = item.segment_number,
3330            article_size = ?decoded.file_size,
3331            pinned_size = ?pinned_size,
3332            "yEnc size= disagrees with the size pinned for this file; the NZB-declared size is the bound"
3333        );
3334    }
3335
3336    let decoded_len = decoded.data.len() as u64;
3337    let data_begin = validated_write_offset(
3338        item.segment_number,
3339        &decoded,
3340        decoded_len,
3341        declared,
3342        if declared.is_some() {
3343            decoded.file_size
3344        } else {
3345            pinned_size
3346        },
3347    )
3348    .map_err(|reason| {
3349        ArticleError::DecodeError(format!(
3350            "Corrupt yEnc article for {} seg {}: {reason}",
3351            item.filename, item.segment_number
3352        ))
3353    })?;
3354    let yenc_filename = decoded.filename;
3355
3356    let assemble_start = Instant::now();
3357    let file_complete = assembler
3358        .assemble_article(
3359            &item.job_id,
3360            &item.file_id,
3361            item.segment_number,
3362            data_begin,
3363            &decoded.data,
3364        )
3365        .map_err(|e| {
3366            ArticleError::AssemblyError(format!(
3367                "Assembly failed for {} seg {}: {e}",
3368                item.filename, item.segment_number
3369            ))
3370        })?;
3371    let assemble_us = assemble_start.elapsed().as_micros();
3372
3373    debug!(
3374        file = %item.filename,
3375        segment = item.segment_number,
3376        raw_bytes = raw_data.len(),
3377        decoded_bytes = decoded_len,
3378        decode_us,
3379        assemble_us,
3380        "Article decode+assemble timing"
3381    );
3382
3383    Ok(ProcessResult {
3384        decoded_bytes: decoded_len,
3385        file_complete,
3386        yenc_filename,
3387        decode_us: decode_us as u64,
3388        assemble_us: assemble_us as u64,
3389    })
3390}
3391
3392// ---------------------------------------------------------------------------
3393// Tests
3394// ---------------------------------------------------------------------------
3395
3396#[cfg(test)]
3397mod tests {
3398    use super::*;
3399
3400    fn worker_pool_without_servers() -> Arc<WorkerPool> {
3401        WorkerPool::new(
3402            Arc::new(Mutex::new(Vec::new())),
3403            Arc::new(BandwidthLimiter::new(Default::default())),
3404            Arc::new(ConnectionTracker::new()),
3405            0,
3406        )
3407    }
3408
3409    #[tokio::test(start_paused = true)]
3410    async fn worker_shutdown_uses_one_shared_deadline() {
3411        let handles = (0..3)
3412            .map(|_| tokio::spawn(std::future::pending()))
3413            .collect();
3414        let shutdown = tokio::spawn(await_worker_shutdown(handles, Duration::from_secs(10)));
3415        tokio::task::yield_now().await;
3416        tokio::time::advance(Duration::from_secs(9)).await;
3417        tokio::task::yield_now().await;
3418        assert!(!shutdown.is_finished());
3419        tokio::time::advance(Duration::from_secs(2)).await;
3420        shutdown.await.unwrap();
3421    }
3422
3423    fn test_job(job_id: &str, root: &std::path::Path) -> NzbJob {
3424        NzbJob {
3425            id: job_id.to_string(),
3426            name: job_id.to_string(),
3427            category: "Default".to_string(),
3428            status: nzb_core::models::JobStatus::Downloading,
3429            priority: nzb_core::models::Priority::Normal,
3430            total_bytes: 1,
3431            downloaded_bytes: 0,
3432            file_count: 1,
3433            files_completed: 0,
3434            article_count: 1,
3435            articles_downloaded: 0,
3436            articles_failed: 0,
3437            added_at: chrono::Utc::now(),
3438            completed_at: None,
3439            work_dir: root.to_path_buf(),
3440            output_dir: root.join("complete"),
3441            password: None,
3442            error_message: None,
3443            speed_bps: 0,
3444            pp_override: None,
3445            delete_archives: None,
3446            server_stats: Vec::new(),
3447            files: Vec::new(),
3448        }
3449    }
3450
3451    fn insert_test_context(pool: &WorkerPool, job: &NzbJob, assembler: Arc<FileAssembler>) {
3452        let (progress_tx, _progress_rx) = mpsc::channel(1);
3453        let ctx = Arc::new(JobContext::new(job, assembler, progress_tx, 1));
3454        pool.job_contexts.lock().insert(job.id.clone(), ctx);
3455    }
3456
3457    #[test]
3458    fn submission_preserves_declared_segment_numbers() {
3459        let temp = tempfile::tempdir().unwrap();
3460        let mut job = test_job("segment-identity", temp.path());
3461        let article = |message_id: &str, segment_number: u32, bytes: u64| nzb_nntp::Article {
3462            message_id: message_id.to_string(),
3463            segment_number,
3464            bytes,
3465            downloaded: false,
3466            data_begin: None,
3467            data_size: None,
3468            crc32: None,
3469            tried_servers: Vec::new(),
3470            tries: 0,
3471        };
3472        job.files.push(nzb_core::models::NzbFile {
3473            id: "file-1".into(),
3474            filename: "out-of-order.bin".into(),
3475            bytes: 1_000,
3476            bytes_downloaded: 0,
3477            is_par2: false,
3478            par2_setname: None,
3479            par2_vol: None,
3480            par2_blocks: None,
3481            assembled: false,
3482            groups: Vec::new(),
3483            articles: vec![article("third", 3, 300), article("first", 1, 100)],
3484        });
3485        let (tx, _rx) = mpsc::channel(4);
3486
3487        let (_ctx, items) = build_job_submission(&job, tx, &["srv1".into()]);
3488
3489        assert_eq!(
3490            items
3491                .iter()
3492                .map(|item| (item.message_id.as_str(), item.segment_number))
3493                .collect::<Vec<_>>(),
3494            vec![("third", 3), ("first", 1)]
3495        );
3496    }
3497
3498    #[test]
3499    fn file_without_segment_bytes_still_assembles() {
3500        let temp = tempfile::tempdir().unwrap();
3501        let mut job = test_job("no-bytes", temp.path());
3502        let article = |message_id: &str, segment_number: u32| nzb_nntp::Article {
3503            message_id: message_id.to_string(),
3504            segment_number,
3505            bytes: 0,
3506            downloaded: false,
3507            data_begin: None,
3508            data_size: None,
3509            crc32: None,
3510            tried_servers: Vec::new(),
3511            tries: 0,
3512        };
3513        job.files.push(nzb_core::models::NzbFile {
3514            id: "file-1".into(),
3515            filename: "out.bin".into(),
3516            bytes: 0,
3517            bytes_downloaded: 0,
3518            is_par2: false,
3519            par2_setname: None,
3520            par2_vol: None,
3521            par2_blocks: None,
3522            assembled: false,
3523            groups: Vec::new(),
3524            articles: vec![article("seg1", 1), article("seg2", 2)],
3525        });
3526        let (tx, _rx) = mpsc::channel(4);
3527        let (ctx, _items) = build_job_submission(&job, tx, &["srv1".into()]);
3528        // A missing bytes= attribute parses as 0, so there is no NZB bound.
3529        assert_eq!(ctx.assembler.declared_bytes(&job.id, "file-1"), None);
3530
3531        let (seg1, _) = yenc_simd::encode_article(b"AAAA", "out.bin", 1, 2, 0, 8);
3532        let (seg2, _) = yenc_simd::encode_article(b"BBBB", "out.bin", 2, 2, 4, 8);
3533        let item = |segment_number| {
3534            let mut item = make_item(&job.id, "msg@test", "out.bin");
3535            item.file_id = "file-1".into();
3536            item.segment_number = segment_number;
3537            item
3538        };
3539        decode_and_assemble(&item(1), &seg1, &ctx.assembler, None).unwrap();
3540        let second = decode_and_assemble(&item(2), &seg2, &ctx.assembler, None).unwrap();
3541
3542        assert!(second.file_complete);
3543        assert_eq!(
3544            std::fs::read(temp.path().join("out.bin")).unwrap(),
3545            b"AAAABBBB"
3546        );
3547    }
3548
3549    #[test]
3550    fn submission_with_segment_gap_completes_when_all_declared_segments_written() {
3551        let temp = tempfile::tempdir().unwrap();
3552        let mut job = test_job("segment-gap", temp.path());
3553        let article = |message_id: &str, segment_number: u32| nzb_nntp::Article {
3554            message_id: message_id.to_string(),
3555            segment_number,
3556            bytes: 4,
3557            downloaded: false,
3558            data_begin: None,
3559            data_size: None,
3560            crc32: None,
3561            tried_servers: Vec::new(),
3562            tries: 0,
3563        };
3564        job.files.push(nzb_core::models::NzbFile {
3565            id: "file-1".into(),
3566            filename: "gap.bin".into(),
3567            bytes: 16,
3568            bytes_downloaded: 0,
3569            is_par2: false,
3570            par2_setname: None,
3571            par2_vol: None,
3572            par2_blocks: None,
3573            assembled: false,
3574            groups: Vec::new(),
3575            // Segment 3 is absent from the NZB.
3576            articles: vec![article("s1", 1), article("s2", 2), article("s4", 4)],
3577        });
3578        let (tx, _rx) = mpsc::channel(4);
3579        let (ctx, _items) = build_job_submission(&job, tx, &["srv1".into()]);
3580
3581        let asm = &ctx.assembler;
3582        assert!(
3583            !asm.assemble_article(&job.id, "file-1", 1, 0, b"AAAA")
3584                .unwrap()
3585        );
3586        assert!(
3587            !asm.assemble_article(&job.id, "file-1", 2, 4, b"BBBB")
3588                .unwrap()
3589        );
3590        // Segment 4 must be accepted and must complete the file.
3591        assert!(
3592            asm.assemble_article(&job.id, "file-1", 4, 12, b"DDDD")
3593                .unwrap()
3594        );
3595        assert!(asm.is_file_complete(&job.id, "file-1"));
3596        assert_eq!(asm.get_file_progress(&job.id, "file-1"), (3, 3));
3597    }
3598
3599    #[test]
3600    fn submission_does_not_requeue_previously_failed_articles() {
3601        let temp = tempfile::tempdir().unwrap();
3602        let mut job = test_job("previously-failed", temp.path());
3603        let article = |message_id: &str, segment_number: u32, tried: &[&str]| nzb_nntp::Article {
3604            message_id: message_id.to_string(),
3605            segment_number,
3606            bytes: 100,
3607            downloaded: false,
3608            data_begin: None,
3609            data_size: None,
3610            crc32: None,
3611            tried_servers: tried.iter().map(|s| s.to_string()).collect(),
3612            tries: tried.len() as u32,
3613        };
3614        job.files.push(nzb_core::models::NzbFile {
3615            id: "file-1".into(),
3616            filename: "partial.bin".into(),
3617            bytes: 200,
3618            bytes_downloaded: 0,
3619            is_par2: false,
3620            par2_setname: None,
3621            par2_vol: None,
3622            par2_blocks: None,
3623            assembled: false,
3624            groups: Vec::new(),
3625            articles: vec![article("pending", 1, &[]), article("failed", 2, &["srv1"])],
3626        });
3627        let (tx, _rx) = mpsc::channel(4);
3628
3629        let (ctx, items) = build_job_submission(&job, tx, &["srv1".into()]);
3630
3631        assert_eq!(
3632            items
3633                .iter()
3634                .map(|item| item.message_id.as_str())
3635                .collect::<Vec<_>>(),
3636            vec!["pending"]
3637        );
3638        assert_eq!(ctx.articles_remaining.load(Ordering::Relaxed), 1);
3639        assert_eq!(ctx.articles_failed.load(Ordering::Relaxed), 1);
3640
3641        // A server enabled since the failure has never been asked, so the
3642        // article is fetched again instead of being skipped.
3643        let (tx, _rx) = mpsc::channel(4);
3644        let (ctx, items) = build_job_submission(&job, tx, &["srv1".into(), "srv2".into()]);
3645        assert_eq!(items.len(), 2);
3646        assert_eq!(ctx.articles_failed.load(Ordering::Relaxed), 0);
3647    }
3648
3649    fn decode_test_item(job_id: &str, segment_number: u32) -> WorkItem {
3650        let mut item = make_item(job_id, "msg@test", "out.bin");
3651        item.segment_number = segment_number;
3652        item
3653    }
3654
3655    #[test]
3656    fn decode_rejects_ypart_offset_beyond_declared_file_size() {
3657        let temp = tempfile::tempdir().unwrap();
3658        let path = temp.path().join("out.bin");
3659        let assembler = FileAssembler::new();
3660        assembler
3661            .register_file("j1", "f1", path.clone(), 2)
3662            .unwrap();
3663
3664        // Part 2 of an 8-byte file claims to start at ~1 TB.
3665        let (raw, _) = yenc_simd::encode_article(b"BBBB", "out.bin", 2, 2, 1_000_000_000_000, 8);
3666        let result = decode_and_assemble(&decode_test_item("j1", 2), &raw, &assembler, None);
3667
3668        assert!(
3669            matches!(result, Err(ArticleError::DecodeError(_))),
3670            "expected corrupt-article error, got {result:?}"
3671        );
3672        assert!(std::fs::metadata(&path).unwrap().len() <= 8);
3673        assert_eq!(assembler.get_file_progress("j1", "f1"), (0, 2));
3674    }
3675
3676    #[test]
3677    fn decode_rejects_forged_size_past_nzb_declared_bytes() {
3678        let temp = tempfile::tempdir().unwrap();
3679        let path = temp.path().join("out.bin");
3680        let assembler = FileAssembler::new();
3681        // The NZB says the file is 8 bytes. The article claims 2 TB and asks
3682        // to be written at 1 TB, which the article's own size= would allow.
3683        assembler
3684            .register_file_with_segment_numbers("j1", "f1", path.clone(), &[1, 2], &[], Some(8))
3685            .unwrap();
3686
3687        let (raw, _) = yenc_simd::encode_article(
3688            b"BBBB",
3689            "out.bin",
3690            2,
3691            2,
3692            1_000_000_000_000,
3693            2_000_000_000_000,
3694        );
3695        let result = decode_and_assemble(&decode_test_item("j1", 2), &raw, &assembler, None);
3696
3697        assert!(
3698            matches!(result, Err(ArticleError::DecodeError(_))),
3699            "forged size= must not win over the NZB size, got {result:?}"
3700        );
3701        assert!(std::fs::metadata(&path).unwrap().len() <= 8);
3702        assert_eq!(assembler.get_file_progress("j1", "f1"), (0, 2));
3703    }
3704
3705    #[test]
3706    fn decode_rejects_missing_size_on_multi_segment_file() {
3707        let temp = tempfile::tempdir().unwrap();
3708        let path = temp.path().join("out.bin");
3709        let assembler = FileAssembler::new();
3710        // No NZB size, and the article carries no size= either.
3711        assembler
3712            .register_file("j1", "f1", path.clone(), 2)
3713            .unwrap();
3714
3715        let (mut raw, _) = yenc_simd::encode_article(b"BBBB", "out.bin", 2, 2, 4, 8);
3716        let header_end = raw.windows(2).position(|w| w == b"\r\n").unwrap();
3717        raw.splice(
3718            0..header_end,
3719            b"=ybegin part=2 line=128 name=out.bin".iter().copied(),
3720        );
3721        let result = decode_and_assemble(&decode_test_item("j1", 2), &raw, &assembler, None);
3722
3723        assert!(
3724            matches!(result, Err(ArticleError::DecodeError(_))),
3725            "a multi-segment file with no bound at all must be rejected, got {result:?}"
3726        );
3727        assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
3728        assert_eq!(assembler.get_file_progress("j1", "f1"), (0, 2));
3729    }
3730
3731    #[test]
3732    fn decode_rejects_ypart_range_overrunning_declared_file_size() {
3733        let temp = tempfile::tempdir().unwrap();
3734        let assembler = FileAssembler::new();
3735        assembler
3736            .register_file("j1", "f1", temp.path().join("out.bin"), 2)
3737            .unwrap();
3738
3739        // Begins inside the file but runs 2 bytes past its declared end.
3740        let (raw, _) = yenc_simd::encode_article(b"BBBB", "out.bin", 2, 2, 6, 8);
3741        let result = decode_and_assemble(&decode_test_item("j1", 2), &raw, &assembler, None);
3742
3743        assert!(matches!(result, Err(ArticleError::DecodeError(_))));
3744        assert_eq!(assembler.get_file_progress("j1", "f1"), (0, 2));
3745    }
3746
3747    #[test]
3748    fn decode_rejects_missing_ypart_on_non_first_segment() {
3749        let temp = tempfile::tempdir().unwrap();
3750        let path = temp.path().join("out.bin");
3751        let assembler = FileAssembler::new();
3752        assembler
3753            .register_file("j1", "f1", path.clone(), 2)
3754            .unwrap();
3755
3756        let (seg1, _) = yenc_simd::encode_article(b"AAAA", "out.bin", 1, 2, 0, 8);
3757        decode_and_assemble(&decode_test_item("j1", 1), &seg1, &assembler, None).unwrap();
3758
3759        // Segment 2 arrives as a single-part article (no =ypart). Writing it
3760        // at offset 0 would overwrite segment 1.
3761        let (seg2, _) = yenc_simd::encode_article(b"ZZZZ", "out.bin", 1, 1, 0, 4);
3762        let result = decode_and_assemble(&decode_test_item("j1", 2), &seg2, &assembler, None);
3763
3764        assert!(matches!(result, Err(ArticleError::DecodeError(_))));
3765        assert_eq!(std::fs::read(&path).unwrap(), b"AAAA");
3766        assert_eq!(assembler.get_file_progress("j1", "f1"), (1, 2));
3767    }
3768
3769    #[test]
3770    fn decode_accepts_valid_parts_and_single_part_articles() {
3771        let temp = tempfile::tempdir().unwrap();
3772        let multi = temp.path().join("multi.bin");
3773        let single = temp.path().join("single.bin");
3774        let assembler = FileAssembler::new();
3775        assembler
3776            .register_file("j1", "f1", multi.clone(), 2)
3777            .unwrap();
3778        assembler
3779            .register_file("j1", "f2", single.clone(), 1)
3780            .unwrap();
3781
3782        let (seg2, _) = yenc_simd::encode_article(b"BBBB", "multi.bin", 2, 2, 4, 8);
3783        let (seg1, _) = yenc_simd::encode_article(b"AAAA", "multi.bin", 1, 2, 0, 8);
3784        assert!(
3785            !decode_and_assemble(&decode_test_item("j1", 2), &seg2, &assembler, None)
3786                .unwrap()
3787                .file_complete
3788        );
3789        assert!(
3790            decode_and_assemble(&decode_test_item("j1", 1), &seg1, &assembler, None)
3791                .unwrap()
3792                .file_complete
3793        );
3794        assert_eq!(std::fs::read(&multi).unwrap(), b"AAAABBBB");
3795
3796        let (whole, _) = yenc_simd::encode_article(b"ONE", "single.bin", 1, 1, 0, 3);
3797        let mut item = decode_test_item("j1", 1);
3798        item.file_id = "f2".into();
3799        assert!(
3800            decode_and_assemble(&item, &whole, &assembler, None)
3801                .unwrap()
3802                .file_complete
3803        );
3804        assert_eq!(std::fs::read(&single).unwrap(), b"ONE");
3805    }
3806
3807    #[tokio::test]
3808    async fn serial_fetch_does_not_write_article_after_job_cancelled() {
3809        use nzb_nntp::testutil::{MockConfig, MockNntpServer, test_config};
3810
3811        let (encoded, _) = yenc_simd::encode_article(b"payload", "out.bin", 1, 1, 0, 7);
3812        let mut articles = HashMap::new();
3813        articles.insert("msg@test".to_string(), encoded);
3814        let server = MockNntpServer::start(MockConfig {
3815            articles,
3816            ..MockConfig::default()
3817        })
3818        .await;
3819        let config = test_config(server.port());
3820        let mut conn = NntpConnection::new(config.id.clone());
3821        conn.connect(&config).await.unwrap();
3822
3823        let temp = tempfile::tempdir().unwrap();
3824        let path = temp.path().join("out.bin");
3825        let assembler = FileAssembler::new();
3826        assembler
3827            .register_file("j1", "f1", path.clone(), 1)
3828            .unwrap();
3829        let item = make_item("j1", "msg@test", "out.bin");
3830
3831        // abort_job() flips the flag while the fetch is in flight.
3832        let cancelled = AtomicBool::new(true);
3833        let result =
3834            fetch_article_with_retry(&mut conn, &item, &assembler, &cancelled, &config, "w0").await;
3835
3836        assert!(
3837            matches!(result, Err(ArticleError::Cancelled)),
3838            "expected Cancelled, got {result:?}"
3839        );
3840        assert_eq!(assembler.get_file_progress("j1", "f1"), (0, 1));
3841        assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
3842
3843        // Sanity: the same fetch succeeds and writes when not cancelled.
3844        let not_cancelled = AtomicBool::new(false);
3845        let result =
3846            fetch_article_with_retry(&mut conn, &item, &assembler, &not_cancelled, &config, "w0")
3847                .await
3848                .unwrap();
3849        assert!(result.file_complete);
3850        assert_eq!(std::fs::read(&path).unwrap(), b"payload");
3851    }
3852
3853    #[cfg(target_os = "linux")]
3854    fn open_fd_count_under(root: &std::path::Path) -> usize {
3855        std::fs::read_dir("/proc/self/fd")
3856            .expect("read /proc/self/fd")
3857            .filter_map(Result::ok)
3858            .filter_map(|entry| std::fs::read_link(entry.path()).ok())
3859            .filter(|target| target.starts_with(root))
3860            .count()
3861    }
3862
3863    #[test]
3864    fn has_known_extension_recognizes_archives() {
3865        assert!(has_known_extension("movie.rar"));
3866        assert!(has_known_extension("movie.part01.rar"));
3867        assert!(has_known_extension("file.zip"));
3868        assert!(has_known_extension("file.7z"));
3869        assert!(has_known_extension("archive.001"));
3870    }
3871
3872    #[test]
3873    fn has_known_extension_recognizes_video() {
3874        assert!(has_known_extension("episode.mkv"));
3875        assert!(has_known_extension("movie.mp4"));
3876        assert!(has_known_extension("video.avi"));
3877        assert!(has_known_extension("clip.ts"));
3878    }
3879
3880    #[test]
3881    fn has_known_extension_recognizes_par2() {
3882        assert!(has_known_extension("file.par2"));
3883        assert!(has_known_extension("file.vol00+01.par2"));
3884        assert!(has_known_extension("file.vol015-031.par2"));
3885    }
3886
3887    #[test]
3888    fn has_known_extension_recognizes_misc() {
3889        assert!(has_known_extension("info.nfo"));
3890        assert!(has_known_extension("sub.srt"));
3891        assert!(has_known_extension("cover.jpg"));
3892        assert!(has_known_extension("song.flac"));
3893    }
3894
3895    #[test]
3896    fn has_known_extension_rejects_obfuscated_hashes() {
3897        assert!(!has_known_extension("9b6a324d7560b87091685020371ba869"));
3898        assert!(!has_known_extension("1fG1GP7L2263LHXH213HTNIxZsX7l0cv44BZ"));
3899        assert!(!has_known_extension("DfKUx3bl7L6PSo6276WSaXSZ7"));
3900        assert!(!has_known_extension("Q77O1ZxL237vc241z77hFoLBxl"));
3901    }
3902
3903    #[test]
3904    fn has_known_extension_rejects_unknown_extensions() {
3905        assert!(!has_known_extension("file.xyz123"));
3906        assert!(!has_known_extension("noext"));
3907        assert!(!has_known_extension(""));
3908    }
3909
3910    #[test]
3911    fn has_known_extension_case_insensitive() {
3912        assert!(has_known_extension("file.RAR"));
3913        assert!(has_known_extension("file.MKV"));
3914        assert!(has_known_extension("file.Par2"));
3915        assert!(has_known_extension("file.MP4"));
3916    }
3917
3918    fn make_item(job_id: &str, msg_id: &str, filename: &str) -> WorkItem {
3919        WorkItem {
3920            job_id: job_id.to_string(),
3921            file_id: "f1".to_string(),
3922            filename: filename.to_string(),
3923            message_id: msg_id.to_string(),
3924            segment_number: 1,
3925            tried_servers: Vec::new(),
3926            provider_outcomes: HashMap::new(),
3927            tries_on_current: 0,
3928            outage_tries: 0,
3929        }
3930    }
3931
3932    #[test]
3933    fn shared_queue_par2_first() {
3934        let q = SharedWorkQueue::new();
3935        q.submit_items(vec![
3936            make_item("j1", "a", "movie.rar"),
3937            make_item("j1", "b", "movie.par2"),
3938            make_item("j1", "c", "movie.vol00+01.par2"),
3939            make_item("j1", "d", "movie.r00"),
3940        ]);
3941        let first = q.pop_workable("srv1", &[]).unwrap();
3942        assert_eq!(first.filename, "movie.par2", "index file first");
3943        let second = q.pop_workable("srv1", &[]).unwrap();
3944        assert_eq!(second.filename, "movie.vol00+01.par2", "vol file second");
3945    }
3946
3947    #[test]
3948    fn shared_queue_skips_tried_servers() {
3949        let q = SharedWorkQueue::new();
3950        let mut item = make_item("j1", "a", "file.rar");
3951        item.tried_servers.push("srv1".to_string());
3952        q.submit_items(vec![item, make_item("j1", "b", "other.rar")]);
3953
3954        // srv1 should skip the first item and return the second.
3955        let picked = q.pop_workable("srv1", &[]).unwrap();
3956        assert_eq!(picked.message_id, "b");
3957    }
3958
3959    #[test]
3960    fn shared_queue_round_robins_eligible_jobs_per_server() {
3961        let q = SharedWorkQueue::new();
3962        q.submit_items(vec![
3963            make_item("job-a", "a-1", "first.rar"),
3964            make_item("job-a", "a-2", "second.rar"),
3965            make_item("job-b", "b-1", "third.rar"),
3966        ]);
3967
3968        assert_eq!(q.pop_workable("srv1", &[]).unwrap().job_id, "job-a");
3969        assert_eq!(q.pop_workable("srv1", &[]).unwrap().job_id, "job-b");
3970        assert_eq!(q.pop_workable("srv1", &[]).unwrap().job_id, "job-a");
3971    }
3972
3973    #[tokio::test]
3974    async fn provider_outage_is_reported_once_then_cleared_on_recovery() {
3975        let temp = tempfile::tempdir().unwrap();
3976        let server = ServerConfig::new("srv1", "one.invalid");
3977        let pool = WorkerPool::new(
3978            Arc::new(Mutex::new(vec![server.clone()])),
3979            Arc::new(BandwidthLimiter::new(Default::default())),
3980            Arc::new(ConnectionTracker::new()),
3981            0,
3982        );
3983        let job = test_job("provider-outage", temp.path());
3984        let (progress_tx, mut progress_rx) = mpsc::channel(4);
3985        let ctx = Arc::new(JobContext::new(
3986            &job,
3987            Arc::new(FileAssembler::new()),
3988            progress_tx,
3989            1,
3990        ));
3991        pool.job_contexts.lock().insert(job.id.clone(), ctx);
3992        pool.server_health
3993            .lock()
3994            .entry(server.id.clone())
3995            .or_default()
3996            .record_failure(false, "connection refused");
3997        pool.server_health
3998            .lock()
3999            .entry(server.id.clone())
4000            .or_default()
4001            .record_failure(false, "connection refused");
4002        pool.server_health
4003            .lock()
4004            .entry(server.id.clone())
4005            .or_default()
4006            .record_failure(false, "connection refused");
4007
4008        pool.report_provider_outage();
4009        pool.report_provider_outage();
4010        match progress_rx.recv().await.unwrap() {
4011            ProgressUpdate::WaitingForProviders { message, .. } => {
4012                assert!(message.contains("retry automatically"));
4013            }
4014            other => panic!("unexpected progress event: {other:?}"),
4015        }
4016        assert!(
4017            progress_rx.try_recv().is_err(),
4018            "outage event is deduplicated"
4019        );
4020
4021        pool.server_health
4022            .lock()
4023            .get_mut(&server.id)
4024            .unwrap()
4025            .record_success();
4026        pool.report_providers_available();
4027        assert!(matches!(
4028            progress_rx.recv().await,
4029            Some(ProgressUpdate::ProvidersAvailable { .. })
4030        ));
4031    }
4032
4033    #[test]
4034    fn unavailable_provider_does_not_prove_global_absence() {
4035        let servers = vec![
4036            ServerConfig::new("srv1", "one.invalid"),
4037            ServerConfig::new("srv2", "two.invalid"),
4038        ];
4039        let mut outcomes = HashMap::new();
4040        outcomes.insert(
4041            "srv1".to_string(),
4042            crate::article_failure::ArticleFailureKind::NotFound,
4043        );
4044
4045        let health = HashMap::new();
4046        assert!(!all_enabled_providers_definitive(
4047            &servers, &health, &outcomes
4048        ));
4049        outcomes.insert(
4050            "srv2".to_string(),
4051            crate::article_failure::ArticleFailureKind::NotFound,
4052        );
4053        assert!(all_enabled_providers_definitive(
4054            &servers, &health, &outcomes
4055        ));
4056    }
4057
4058    #[test]
4059    fn unavailable_optional_provider_is_not_required() {
4060        let mut optional = ServerConfig::new("fill", "fill.invalid");
4061        optional.optional = true;
4062        let servers = vec![ServerConfig::new("main", "main.invalid"), optional];
4063        let mut outcomes = HashMap::new();
4064        outcomes.insert(
4065            "main".to_string(),
4066            crate::article_failure::ArticleFailureKind::NotFound,
4067        );
4068        let mut health = HashMap::new();
4069
4070        // A healthy optional server is still asked.
4071        assert!(!all_enabled_providers_definitive(
4072            &servers, &health, &outcomes
4073        ));
4074
4075        let mut down = ServerHealth::new();
4076        down.record_failure(true, "502 on connect");
4077        health.insert("fill".to_string(), down);
4078        assert!(all_enabled_providers_definitive(
4079            &servers, &health, &outcomes
4080        ));
4081
4082        // A required server that is down still blocks the decision.
4083        let mut main_down = ServerHealth::new();
4084        main_down.record_failure(true, "502 on connect");
4085        health.insert("main".to_string(), main_down);
4086        outcomes.clear();
4087        assert!(!all_enabled_providers_definitive(
4088            &servers, &health, &outcomes
4089        ));
4090    }
4091
4092    #[tokio::test]
4093    async fn supervisor_sweep_fails_items_left_waiting_on_down_optional_server() {
4094        let temp = tempfile::tempdir().unwrap();
4095        let pool = worker_pool_without_servers();
4096        let mut optional = ServerConfig::new("fill", "fill.invalid");
4097        optional.optional = true;
4098        *pool.servers.lock() = vec![ServerConfig::new("main", "main.invalid"), optional];
4099
4100        let job = test_job("sweep", temp.path());
4101        let (progress_tx, mut progress_rx) = mpsc::channel(8);
4102        let ctx = Arc::new(JobContext::new(
4103            &job,
4104            Arc::new(FileAssembler::new()),
4105            progress_tx,
4106            1,
4107        ));
4108        pool.job_contexts.lock().insert(job.id.clone(), ctx.clone());
4109        let mut item = make_item("sweep", "missing@test", "payload.bin");
4110        item.tried_servers.push("main".into());
4111        item.provider_outcomes.insert(
4112            "main".into(),
4113            crate::article_failure::ArticleFailureKind::NotFound,
4114        );
4115        pool.work_queue.push_front(item);
4116
4117        // Optional server still healthy: the item keeps waiting for it.
4118        pool.resolve_items_not_waiting_on_required_providers();
4119        assert_eq!(pool.work_queue.len(), 1);
4120
4121        // It goes down after the main server answered.
4122        let mut down = ServerHealth::new();
4123        down.record_failure(true, "502 on connect");
4124        pool.server_health.lock().insert("fill".into(), down);
4125        pool.resolve_items_not_waiting_on_required_providers();
4126
4127        assert_eq!(pool.work_queue.len(), 0);
4128        assert_eq!(ctx.articles_failed.load(Ordering::Relaxed), 1);
4129        assert_eq!(ctx.articles_remaining.load(Ordering::Relaxed), 0);
4130        let mut saw_failure = false;
4131        while let Ok(update) = progress_rx.try_recv() {
4132            saw_failure |= matches!(update, ProgressUpdate::ArticleFailed { .. });
4133        }
4134        assert!(saw_failure);
4135    }
4136
4137    #[tokio::test]
4138    async fn abort_has_one_owner_and_waits_for_in_flight_resolution() {
4139        let temp = tempfile::tempdir().unwrap();
4140        let pool = worker_pool_without_servers();
4141        let job = test_job("abort-drain", temp.path());
4142        let assembler = Arc::new(FileAssembler::new());
4143        let (progress_tx, mut progress_rx) = mpsc::channel(8);
4144        let ctx = Arc::new(JobContext::new(&job, assembler, progress_tx, 3));
4145        ctx.articles_failed.store(2, Ordering::Relaxed);
4146        pool.job_contexts
4147            .lock()
4148            .insert(job.id.clone(), Arc::clone(&ctx));
4149        pool.work_queue.submit_items(vec![
4150            make_item(&job.id, "queued-1", "file.rar"),
4151            make_item(&job.id, "queued-2", "file.rar"),
4152        ]);
4153
4154        assert!(pool.abort_job(&job.id, "original reason".into()));
4155        assert!(!pool.abort_job(&job.id, "overwritten reason".into()));
4156        assert_eq!(ctx.articles_remaining.load(Ordering::Relaxed), 1);
4157        assert!(!ctx.finished.load(Ordering::Relaxed));
4158        assert!(progress_rx.try_recv().is_err());
4159
4160        // Simulate the sole in-flight worker settling after its write handle
4161        // is no longer usable. Only now may the terminal event be published.
4162        ctx.resolve_one();
4163        match progress_rx.recv().await.unwrap() {
4164            ProgressUpdate::JobAborted {
4165                reason,
4166                articles_failed,
4167                download_time_secs,
4168                ..
4169            } => {
4170                assert_eq!(reason, "original reason");
4171                assert_eq!(articles_failed, 2);
4172                assert!(download_time_secs >= 0.0);
4173            }
4174            other => panic!("unexpected terminal event: {other:?}"),
4175        }
4176    }
4177
4178    #[test]
4179    fn pop_workable_respects_priority() {
4180        // Fresh item (tried_servers empty). A backup-priority caller whose
4181        // higher_priority_servers list is non-empty must NOT get the item —
4182        // the primary server still needs a chance first.
4183        let q = SharedWorkQueue::new();
4184        q.submit_items(vec![make_item("j1", "a", "file.rar")]);
4185
4186        let higher = vec!["srv_primary".to_string()];
4187        assert!(q.pop_workable("srv_backup", &higher).is_none());
4188
4189        // Primary server (empty higher list) should still get it.
4190        let item = q.pop_workable("srv_primary", &[]).unwrap();
4191        assert_eq!(item.message_id, "a");
4192    }
4193
4194    #[test]
4195    fn pop_workable_allows_backup_after_primary_tried() {
4196        // Once the primary has been added to tried_servers (because it
4197        // failed), the backup is allowed to take the item.
4198        let q = SharedWorkQueue::new();
4199        let mut item = make_item("j1", "a", "file.rar");
4200        item.tried_servers.push("srv_primary".to_string());
4201        q.submit_items(vec![item]);
4202
4203        let higher = vec!["srv_primary".to_string()];
4204        let picked = q.pop_workable("srv_backup", &higher).unwrap();
4205        assert_eq!(picked.message_id, "a");
4206    }
4207
4208    #[test]
4209    fn pop_workable_ignores_circuit_broken_higher_server() {
4210        // When the caller's `higher_priority_servers` list is empty because
4211        // the primary was filtered out as circuit-broken/disabled, the backup
4212        // gets items immediately — no waiting for the dead primary.
4213        let q = SharedWorkQueue::new();
4214        q.submit_items(vec![make_item("j1", "a", "file.rar")]);
4215
4216        let higher: Vec<String> = vec![]; // primary filtered out by caller
4217        let item = q.pop_workable("srv_backup", &higher).unwrap();
4218        assert_eq!(item.message_id, "a");
4219    }
4220
4221    #[test]
4222    fn workable_count_for_respects_priority() {
4223        let q = SharedWorkQueue::new();
4224        q.submit_items(vec![
4225            make_item("j1", "a", "a.rar"),
4226            make_item("j1", "b", "b.rar"),
4227        ]);
4228
4229        // Backup: both items need primary first → workable=0.
4230        let higher = vec!["srv_primary".to_string()];
4231        let (workable, total) = q.workable_count_for("srv_backup", &higher);
4232        assert_eq!(workable, 0);
4233        assert_eq!(total, 2);
4234
4235        // Primary: both items are workable.
4236        let (workable, total) = q.workable_count_for("srv_primary", &[]);
4237        assert_eq!(workable, 2);
4238        assert_eq!(total, 2);
4239    }
4240
4241    #[test]
4242    fn shared_queue_drain_job_removes_only_target() {
4243        let q = SharedWorkQueue::new();
4244        q.submit_items(vec![
4245            make_item("j1", "a", "a.rar"),
4246            make_item("j2", "b", "b.rar"),
4247            make_item("j1", "c", "c.rar"),
4248        ]);
4249        let drained = q.drain_job("j1");
4250        assert_eq!(drained.len(), 2);
4251        assert_eq!(q.len(), 1);
4252        let remaining = q.pop_workable("srv1", &[]).unwrap();
4253        assert_eq!(remaining.job_id, "j2");
4254    }
4255
4256    #[test]
4257    fn release_completed_job_drops_context_and_closes_assembler_files() {
4258        let tempdir = tempfile::tempdir().expect("tempdir");
4259        let assembler = Arc::new(FileAssembler::new());
4260        let job_id = "completed-job";
4261        assembler
4262            .register_file(job_id, "file-1", tempdir.path().join("file.rar"), 1)
4263            .expect("register file");
4264        assert_eq!(assembler.get_file_progress(job_id, "file-1"), (0, 1));
4265
4266        let job = test_job(job_id, tempdir.path());
4267        let pool = worker_pool_without_servers();
4268        insert_test_context(&pool, &job, Arc::clone(&assembler));
4269        assert!(pool.has_job(job_id));
4270
4271        pool.release_completed_job(job_id);
4272
4273        assert!(!pool.has_job(job_id));
4274        assert_eq!(assembler.get_file_progress(job_id, "file-1"), (0, 0));
4275    }
4276
4277    #[test]
4278    fn job_active_secs_reports_registered_jobs_only() {
4279        let tempdir = tempfile::tempdir().expect("tempdir");
4280        let job = test_job("active-secs", tempdir.path());
4281        let pool = worker_pool_without_servers();
4282        assert_eq!(pool.job_active_secs(&job.id), None);
4283
4284        insert_test_context(&pool, &job, Arc::new(FileAssembler::new()));
4285        pool.pause_job(&job.id);
4286        let paused = pool.job_active_secs(&job.id).expect("registered job");
4287        assert!(paused >= 0.0);
4288        // A paused job's clock is stopped.
4289        assert_eq!(pool.job_active_secs(&job.id), Some(paused));
4290
4291        pool.release_completed_job(&job.id);
4292        assert_eq!(pool.job_active_secs(&job.id), None);
4293    }
4294
4295    #[cfg(target_os = "linux")]
4296    #[test]
4297    fn repeated_completed_jobs_do_not_accumulate_file_descriptors() {
4298        const JOBS: usize = 64;
4299        const FILES_PER_JOB: usize = 8;
4300
4301        let tempdir = tempfile::tempdir().expect("tempdir");
4302        let pool = worker_pool_without_servers();
4303        assert_eq!(open_fd_count_under(tempdir.path()), 0);
4304
4305        for job_index in 0..JOBS {
4306            let job_id = format!("job-{job_index}");
4307            let job_dir = tempdir.path().join(&job_id);
4308            let assembler = Arc::new(FileAssembler::new());
4309            for file_index in 0..FILES_PER_JOB {
4310                assembler
4311                    .register_file(
4312                        &job_id,
4313                        &format!("file-{file_index}"),
4314                        job_dir.join(format!("file-{file_index}.rar")),
4315                        1,
4316                    )
4317                    .expect("register file");
4318            }
4319            assert_eq!(open_fd_count_under(tempdir.path()), FILES_PER_JOB);
4320
4321            let job = test_job(&job_id, &job_dir);
4322            insert_test_context(&pool, &job, assembler);
4323            pool.release_completed_job(&job_id);
4324
4325            assert!(!pool.has_job(&job_id));
4326            assert_eq!(
4327                open_fd_count_under(tempdir.path()),
4328                0,
4329                "completed job {job_index} retained output file descriptors"
4330            );
4331        }
4332
4333        assert!(pool.job_contexts.lock().is_empty());
4334    }
4335
4336    // -----------------------------------------------------------------------
4337    // ConnectionTracker (Phase 4 — semaphore-backed slots)
4338    // -----------------------------------------------------------------------
4339
4340    #[tokio::test]
4341    async fn connection_tracker_acquire_releases_slot_on_drop() {
4342        let t = ConnectionTracker::new();
4343        t.set_limit("srv1", "Server 1", 2);
4344        let s1 = t.acquire("srv1").await.unwrap();
4345        let s2 = t.acquire("srv1").await.unwrap();
4346        assert_eq!(t.total(), 2);
4347        // Synchronous release on drop.
4348        drop(s1);
4349        assert_eq!(t.total(), 1);
4350        drop(s2);
4351        assert_eq!(t.total(), 0);
4352    }
4353
4354    #[tokio::test]
4355    async fn connection_tracker_blocks_at_limit() {
4356        let t = Arc::new(ConnectionTracker::new());
4357        t.set_limit("srv1", "Server 1", 1);
4358        let _held = t.acquire("srv1").await.unwrap();
4359
4360        // Third acquire on a 1-slot pool must block. Wrap with a short
4361        // timeout — if it does NOT time out, the cap was breached.
4362        let t2 = Arc::clone(&t);
4363        let res = tokio::time::timeout(Duration::from_millis(150), async move {
4364            t2.acquire("srv1").await
4365        })
4366        .await;
4367        assert!(
4368            res.is_err(),
4369            "second acquire should block while limit is reached"
4370        );
4371    }
4372
4373    #[tokio::test]
4374    async fn connection_tracker_grow_in_place_lets_more_acquire() {
4375        let t = ConnectionTracker::new();
4376        t.set_limit("srv1", "Server 1", 2);
4377        let _a = t.acquire("srv1").await.unwrap();
4378        let _b = t.acquire("srv1").await.unwrap();
4379        // No more capacity — but grow to 4 and we should be able to take 2
4380        // more without releasing the existing slots.
4381        t.set_limit("srv1", "Server 1", 4);
4382        let _c = t.acquire("srv1").await.unwrap();
4383        let _d = t.acquire("srv1").await.unwrap();
4384        assert_eq!(t.total(), 4);
4385    }
4386
4387    #[tokio::test]
4388    async fn connection_tracker_shrink_marks_old_slots_stale() {
4389        let t = ConnectionTracker::new();
4390        t.set_limit("srv1", "Server 1", 4);
4391        let s = t.acquire("srv1").await.unwrap();
4392        assert!(t.slot_is_current(&s));
4393
4394        // Shrink to 1 — old semaphore is replaced.
4395        t.set_limit("srv1", "Server 1", 1);
4396        assert!(
4397            !t.slot_is_current(&s),
4398            "after shrink, the previously-acquired slot must be marked stale"
4399        );
4400
4401        // The new pool starts empty (1 permit available, 0 in use). Old
4402        // permit holder is no longer counted in `total()` because its
4403        // semaphore is orphaned.
4404        assert_eq!(t.total(), 0);
4405
4406        // We can still acquire from the new pool.
4407        let new_slot = t.acquire("srv1").await.unwrap();
4408        assert!(t.slot_is_current(&new_slot));
4409        assert_eq!(t.total(), 1);
4410    }
4411
4412    #[tokio::test]
4413    async fn connection_tracker_remove_server_marks_slot_stale() {
4414        let t = ConnectionTracker::new();
4415        t.set_limit("srv1", "Server 1", 2);
4416        let s = t.acquire("srv1").await.unwrap();
4417        assert!(t.slot_is_current(&s));
4418
4419        t.remove_server("srv1");
4420        assert!(
4421            !t.slot_is_current(&s),
4422            "after remove_server, the slot must be marked stale"
4423        );
4424        assert_eq!(t.total(), 0);
4425    }
4426
4427    #[tokio::test]
4428    async fn connection_tracker_snapshot_reflects_active_count() {
4429        let t = ConnectionTracker::new();
4430        t.set_limit("srv1", "Server 1", 3);
4431        t.set_limit("srv2", "Server 2", 5);
4432
4433        let _a1 = t.acquire("srv1").await.unwrap();
4434        let _a2 = t.acquire("srv1").await.unwrap();
4435        let _b1 = t.acquire("srv2").await.unwrap();
4436
4437        let mut snap = t.snapshot();
4438        snap.sort_by(|a, b| a.0.cmp(&b.0));
4439        assert_eq!(snap.len(), 2);
4440        assert_eq!(snap[0], ("srv1".into(), 2, 3));
4441        assert_eq!(snap[1], ("srv2".into(), 1, 5));
4442    }
4443
4444    #[tokio::test]
4445    async fn active_snapshot_counts_only_article_activity() {
4446        let t = ConnectionTracker::new();
4447        t.set_limit("srv1", "Server 1", 3);
4448
4449        let mut connected = t.acquire("srv1").await.unwrap();
4450        let _disconnected = t.acquire("srv1").await.unwrap();
4451        assert_eq!(t.connected_snapshot(), vec![("srv1".into(), 0, 3)]);
4452
4453        connected.mark_active();
4454        assert_eq!(t.connected_snapshot(), vec![("srv1".into(), 1, 3)]);
4455
4456        connected.mark_inactive();
4457        assert_eq!(t.connected_snapshot(), vec![("srv1".into(), 0, 3)]);
4458    }
4459
4460    #[tokio::test]
4461    async fn activity_guard_clears_count_when_fetch_scope_ends() {
4462        let t = ConnectionTracker::new();
4463        t.set_limit("srv1", "Server 1", 1);
4464        let mut slot = t.acquire("srv1").await.unwrap();
4465
4466        {
4467            let _activity = slot.activity();
4468            assert_eq!(t.connected_snapshot(), vec![("srv1".into(), 1, 1)]);
4469        }
4470
4471        assert_eq!(t.connected_snapshot(), vec![("srv1".into(), 0, 1)]);
4472        assert_eq!(t.snapshot(), vec![("srv1".into(), 1, 1)]);
4473    }
4474
4475    #[test]
4476    fn only_provider_level_errors_count_against_circuit_breaker() {
4477        let s = || "x".to_string();
4478        for e in [
4479            NntpError::Connection(s()),
4480            NntpError::Tls(s()),
4481            NntpError::Auth(s()),
4482            NntpError::AuthRequired(s()),
4483            NntpError::PermissionDenied(s()),
4484            NntpError::ServiceUnavailable(s()),
4485            NntpError::Timeout(s()),
4486        ] {
4487            assert!(counts_against_circuit_breaker(&e), "{e}");
4488        }
4489        for e in [
4490            NntpError::ArticleNotFound(s()),
4491            NntpError::NoSuchGroup(s()),
4492            NntpError::NoArticleSelected(s()),
4493            NntpError::Protocol(s()),
4494        ] {
4495            assert!(!counts_against_circuit_breaker(&e), "{e}");
4496        }
4497    }
4498}