Skip to main content

aria2_core/engine/
download_engine.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5use tokio::sync::{Mutex, RwLock, mpsc, oneshot};
6use tokio::task::{AbortHandle, Id, JoinSet};
7use tokio::time::interval;
8use tracing::{debug, error, info, warn};
9
10use super::command::{Command, ProgressUpdate};
11use crate::constants;
12use crate::dns::dns_cache::DnsCache;
13use crate::error::{Aria2Error, RecoverableError, Result};
14use crate::ftp::FtpConnectionPool;
15use crate::rate_limiter::{RateLimiter, RateLimiterConfig};
16use crate::request::request_group::RequestGroup;
17use crate::request::request_group_man::RequestGroupMan;
18use crate::retry::{RetryPolicy, RetryStats};
19use crate::session::auto_save_session::AutoSaveSession;
20use crate::session::save_session_command::SaveSessionCommand;
21
22/// Bookkeeping the engine retains for each spawned command task so it can
23/// enforce per-command timeouts and abort stalled tasks individually.
24///
25/// The command object itself is moved into the spawned task (it owns the
26/// `Box<dyn Command>` for the duration of `execute()`); v1 does NOT recover
27/// commands for retry on timeout/failure, so the engine only needs the abort
28/// handle and timing metadata.
29struct RunningTask {
30    /// Handle used to cancel the task when its timeout elapses or on shutdown.
31    handle: AbortHandle,
32    /// Instant the task was spawned.
33    started: Instant,
34    /// Per-command timeout. `None` means the command never times out.
35    timeout: Option<Duration>,
36}
37
38pub struct DownloadEngine {
39    command_tx: mpsc::UnboundedSender<Box<dyn Command>>,
40    command_rx: mpsc::UnboundedReceiver<Box<dyn Command>>,
41    shutdown_tx: Option<oneshot::Sender<()>>,
42    shutdown_rx: Option<oneshot::Receiver<()>>,
43    tick_interval: Duration,
44    retry_policy: Arc<RetryPolicy>,
45    retry_stats: Arc<RetryStats>,
46    global_limiter: Option<RateLimiter>,
47    save_session_path: Option<PathBuf>,
48    save_session_interval: Option<Duration>,
49    request_group_man: Option<Arc<RwLock<RequestGroupMan>>>,
50    auto_save: Option<Arc<Mutex<AutoSaveSession>>>,
51    /// FTP connection pool for connection reuse across FTP downloads.
52    /// Created during engine initialization and passed down via dependency injection.
53    ftp_pool: Arc<FtpConnectionPool>,
54    /// DNS resolution cache for avoiding repeated lookups.
55    /// Created during engine initialization and passed down via dependency injection.
56    dns_cache: Arc<Mutex<DnsCache>>,
57    /// When true, the engine stays alive even with no pending/running commands
58    /// (used for RPC listen mode). The loop only exits on shutdown signal.
59    keep_alive: bool,
60}
61
62impl DownloadEngine {
63    pub fn new(tick_interval_ms: u64) -> Self {
64        Self::with_retry_policy(tick_interval_ms, RetryPolicy::default())
65    }
66
67    pub fn with_retry_policy(tick_interval_ms: u64, policy: RetryPolicy) -> Self {
68        let (command_tx, command_rx) = mpsc::unbounded_channel();
69        let (shutdown_tx, shutdown_rx) = oneshot::channel();
70
71        let max_tries = policy.max_tries();
72
73        let engine = DownloadEngine {
74            command_tx,
75            command_rx,
76            shutdown_tx: Some(shutdown_tx),
77            shutdown_rx: Some(shutdown_rx),
78            tick_interval: Duration::from_millis(tick_interval_ms),
79            retry_policy: Arc::new(policy),
80            retry_stats: Arc::new(RetryStats::default()),
81            global_limiter: None,
82            save_session_path: None,
83            save_session_interval: None,
84            request_group_man: None,
85            auto_save: None,
86            ftp_pool: Arc::new(FtpConnectionPool::new(
87                constants::FTP_POOL_DEFAULT_MAX_CONNECTIONS,
88            )),
89            dns_cache: Arc::new(Mutex::new(DnsCache::new())),
90            keep_alive: false,
91        };
92
93        info!(
94            "Download engine initialization complete, tick interval: {}ms, max retries: {}",
95            tick_interval_ms, max_tries
96        );
97
98        engine
99    }
100
101    pub fn set_global_rate_limiter(&mut self, config: RateLimiterConfig) {
102        self.global_limiter = Some(RateLimiter::new(&config));
103        info!(
104            "Global speed limits set: download={:?}, upload={:?}",
105            config.download_rate(),
106            config.upload_rate()
107        );
108    }
109
110    pub fn global_rate_limiter(&self) -> Option<&RateLimiter> {
111        self.global_limiter.as_ref()
112    }
113
114    pub fn take_global_rate_limiter(&mut self) -> Option<RateLimiter> {
115        self.global_limiter.take()
116    }
117
118    /// Spawn a progress aggregator task that receives [`ProgressUpdate`]s from
119    /// download commands and applies them to the shared [`RequestGroup`].
120    ///
121    /// This eliminates per-chunk write-lock contention on the download hot
122    /// path: each `DownloadCommand` performs a cheap lock-free
123    /// `mpsc::UnboundedSender::send` and this single aggregator task is the
124    /// only writer of the progress fields.
125    ///
126    /// The aggregator deduplicates consecutive updates with identical
127    /// `completed_bytes` values and only refreshes the speed fields when the
128    /// sender provides a non-zero `download_speed` sample (0 means "no fresh
129    /// sample this tick").
130    ///
131    /// The task exits cleanly when all senders are dropped (the receiver
132    /// returns `None`).
133    ///
134    /// This is intentionally an associated function (not `&self`): it is
135    /// called automatically by
136    /// [`DownloadCommand::spawn_progress_aggregator`](crate::engine::download_command::DownloadCommand::spawn_progress_aggregator)
137    /// during `execute()`, since every `DownloadCommand` now auto-creates a
138    /// progress channel in its constructor. External callers rarely need to
139    /// invoke this directly.
140    pub fn spawn_progress_aggregator(
141        group: Arc<RwLock<RequestGroup>>,
142        mut receiver: mpsc::UnboundedReceiver<ProgressUpdate>,
143    ) -> tokio::task::JoinHandle<()> {
144        tokio::spawn(async move {
145            let mut last_bytes: u64 = 0;
146            while let Some(update) = receiver.recv().await {
147                // Skip no-op updates: identical completed_bytes means nothing changed
148                // since the last applied update (e.g. a stale in-flight send).
149                if update.completed_bytes == last_bytes {
150                    continue;
151                }
152                let g = group.write().await;
153                g.update_progress(update.completed_bytes).await;
154                g.set_completed_length(update.completed_bytes);
155                if update.download_speed > 0 {
156                    g.update_speed(update.download_speed, update.upload_speed)
157                        .await;
158                    g.set_download_speed_cached(update.download_speed);
159                }
160                last_bytes = update.completed_bytes;
161            }
162        })
163    }
164
165    pub fn set_save_session(
166        &mut self,
167        path: PathBuf,
168        interval: Option<Duration>,
169        man: Arc<RwLock<RequestGroupMan>>,
170    ) {
171        self.save_session_path = Some(path.clone());
172        self.save_session_interval = interval;
173        self.request_group_man = Some(man);
174
175        if let (Some(interval), Some(man_ref)) = (interval, &self.request_group_man) {
176            let path_clone = path.clone();
177            let auto_save = AutoSaveSession::new(path, interval, man_ref.clone());
178            self.auto_save = Some(Arc::new(Mutex::new(auto_save)));
179            info!(
180                "Auto-save session enabled: path={}, interval={:.1}s",
181                path_clone.display(),
182                interval.as_secs_f64()
183            );
184        } else {
185            info!("Manual save session enabled: path={}", path.display());
186        }
187    }
188
189    pub fn mark_session_dirty(&self) {
190        if let Some(ref auto_save) = self.auto_save
191            && let Ok(auto) = auto_save.try_lock()
192        {
193            auto.mark_dirty();
194        }
195    }
196
197    pub fn save_session_path(&self) -> Option<&PathBuf> {
198        self.save_session_path.as_ref()
199    }
200
201    pub fn add_command(&self, command: Box<dyn Command>) -> Result<()> {
202        self.command_tx
203            .send(command)
204            .map_err(|e| Aria2Error::DownloadFailed(format!("Failed to add command: {}", e)))
205    }
206
207    pub fn retry_stats(&self) -> &RetryStats {
208        &self.retry_stats
209    }
210
211    pub fn retry_policy(&self) -> &RetryPolicy {
212        &self.retry_policy
213    }
214
215    /// Get a reference to the FTP connection pool for dependency injection.
216    pub fn ftp_pool(&self) -> &Arc<FtpConnectionPool> {
217        &self.ftp_pool
218    }
219
220    /// Get a reference to the DNS cache for dependency injection.
221    pub fn dns_cache(&self) -> &Arc<Mutex<DnsCache>> {
222        &self.dns_cache
223    }
224
225    /// Enable/disable keep-alive mode. When true, the engine stays alive even
226    /// with no pending/running commands (used for RPC listen mode). The loop
227    /// only exits on shutdown signal.
228    pub fn set_keep_alive(&mut self, v: bool) {
229        self.keep_alive = v;
230    }
231
232    /// Clone the command sender so external callers (e.g., RPC) can submit
233    /// download commands to the engine loop.
234    pub fn command_sender(&self) -> mpsc::UnboundedSender<Box<dyn Command>> {
235        self.command_tx.clone()
236    }
237
238    /// Take the shutdown sender so an external task (e.g., Ctrl+C handler) can
239    /// signal the engine to stop. Must be called before `run()`.
240    pub fn take_shutdown_sender(&mut self) -> Option<oneshot::Sender<()>> {
241        self.shutdown_tx.take()
242    }
243
244    pub async fn run(mut self) -> Result<()> {
245        info!("Download engine started");
246
247        let mut pending_commands: Vec<Box<dyn Command>> = Vec::new();
248        // Spawned command tasks. Each task owns its `Box<dyn Command>` for the
249        // duration of `execute()`, so commands run CONCURRENTLY instead of
250        // serially blocking the engine loop (Task A2 fix). v1 does NOT recover
251        // commands for retry on timeout/failure; progress is preserved via
252        // session auto-save.
253        let mut running: JoinSet<Result<()>> = JoinSet::new();
254        let mut running_tasks: HashMap<Id, RunningTask> = HashMap::new();
255        // Reserved for future retry support; v1 never populates this (failed /
256        // timed-out commands are dropped, not retried), but the slot is kept so
257        // the exit condition and retry plumbing remain intact.
258        let mut failed_commands: Vec<(Box<dyn Command>, u32)> = Vec::new();
259
260        let mut ticker = interval(self.tick_interval);
261        let mut shutdown_rx = self
262            .shutdown_rx
263            .take()
264            .expect("shutdown_rx should exist in run()");
265        let policy = self.retry_policy.clone();
266        let stats = self.retry_stats.clone();
267
268        loop {
269            tokio::select! {
270                _ = ticker.tick() => {
271                    debug!("Engine tick triggered");
272
273                    // 1. Retry previously failed commands (v1: always empty).
274                    for (cmd, attempt) in failed_commands.drain(..) {
275                        if policy.should_retry(attempt, &Aria2Error::Recoverable(RecoverableError::Timeout)) {
276                            let wait = policy.wait_duration(attempt);
277                            warn!("Retrying command (attempt {}), waiting {:?}", attempt + 1, wait);
278                            pending_commands.push(cmd);
279                            tokio::time::sleep(wait).await;
280                        } else {
281                            error!("Command retry abandoned (attempted {} times)", attempt + 1);
282                        }
283                    }
284
285                    // 2. Spawn pending commands concurrently (Task A2 fix).
286                    self.dispatch_commands(
287                        &mut pending_commands,
288                        &mut running,
289                        &mut running_tasks,
290                    )
291                    .await?;
292
293                    // 3. Abort tasks whose per-command timeout elapsed (Task A1 fix).
294                    self.check_timeouts(&mut running_tasks, &stats).await?;
295
296                    // 4. Reap finished / aborted tasks.
297                    self.collect_completed(&mut running, &mut running_tasks).await?;
298
299                    // 5. Exit when nothing remains (unless keep-alive). Use the
300                    //    JoinSet's own emptiness as the source of truth: an
301                    //    aborted-but-not-yet-reaped task still counts as running
302                    //    until collect_completed joins it.
303                    if !self.keep_alive
304                        && pending_commands.is_empty()
305                        && running.is_empty()
306                        && failed_commands.is_empty()
307                    {
308                        info!("All tasks completed, engine shutting down");
309                        break;
310                    }
311                }
312
313                Ok(_) = &mut shutdown_rx => {
314                    info!("Shutdown signal received");
315                    self.shutdown(&mut running).await;
316                    break;
317                }
318            }
319        }
320
321        info!(
322            "Download engine stopped, retry stats: total={}, timeouts={}, server_errors={}, network_failures={}",
323            stats.total(),
324            stats.timeouts(),
325            stats.server_errors(),
326            stats.network_failures()
327        );
328        Ok(())
329    }
330
331    /// Drain newly-arrived commands from the channel and spawn each as an
332    /// independent task on the `JoinSet` so they execute CONCURRENTLY instead
333    /// of serially blocking the engine loop (Task A2 fix).
334    ///
335    /// Each `Box<dyn Command>` is moved into its spawned task, which owns it
336    /// for the duration of `execute()`. This avoids any shared-state locking
337    /// between the engine and the task, maximizing concurrency.
338    async fn dispatch_commands(
339        &mut self,
340        pending: &mut Vec<Box<dyn Command>>,
341        running: &mut JoinSet<Result<()>>,
342        running_tasks: &mut HashMap<Id, RunningTask>,
343    ) -> Result<()> {
344        // Pull any commands that arrived since the last tick.
345        while let Ok(cmd) = self.command_rx.try_recv() {
346            pending.push(cmd);
347        }
348
349        while !pending.is_empty() {
350            let mut cmd = pending.remove(0);
351            // Inform the command of its start instant (no-op by default) so
352            // commands that override set_started_at can self-report elapsed.
353            cmd.set_started_at(Instant::now());
354            let timeout = cmd.timeout();
355
356            // The command is moved into the spawned task. The task calls
357            // `execute(&mut self)` on its owned command and returns the
358            // `Result<()>`. On abort the command is dropped with the task.
359            let handle = running.spawn(async move {
360                let mut cmd = cmd;
361                cmd.execute().await
362            });
363            let id = handle.id();
364            running_tasks.insert(
365                id,
366                RunningTask {
367                    handle,
368                    started: Instant::now(),
369                    timeout,
370                },
371            );
372            debug!(
373                "Dispatched command (task {}), running: {}",
374                id,
375                running.len()
376            );
377        }
378        Ok(())
379    }
380
381    /// Abort any running task whose per-command timeout has elapsed (Task A1
382    /// fix).
383    ///
384    /// The previous implementation awaited `tokio::time::timeout(dur, async
385    /// {})` on an EMPTY future that resolved instantly, so no command ever
386    /// timed out. This rewrite tracks each task's start instant and aborts the
387    /// spawned task via its `AbortHandle` once the elapsed time exceeds the
388    /// command's `timeout()`.
389    ///
390    /// v1 limitation: a timed-out command is owned by its spawned task and
391    /// cannot be safely recovered for retry, so it is dropped when the aborted
392    /// task is reaped by `collect_completed`. Progress is preserved via session
393    /// auto-save. Auto-retry on timeout is deferred to a follow-up that retains
394    /// command ownership in the engine.
395    async fn check_timeouts(
396        &self,
397        running_tasks: &mut HashMap<Id, RunningTask>,
398        stats: &RetryStats,
399    ) -> Result<()> {
400        let now = Instant::now();
401        // Collect ids whose timeout has elapsed. We cannot mutate
402        // running_tasks while iterating it, so gather first.
403        let timed_out: Vec<Id> = running_tasks
404            .iter()
405            .filter_map(|(id, task)| {
406                let dur = task.timeout?;
407                if now.duration_since(task.started) > dur {
408                    Some(*id)
409                } else {
410                    None
411                }
412            })
413            .collect();
414
415        for id in timed_out {
416            if let Some(task) = running_tasks.remove(&id) {
417                warn!("Command (task {}) timed out, aborting", id);
418                // Abort the spawned task. The task's owned command is dropped
419                // when the runtime reaps the cancelled future (reaped by
420                // collect_completed on a subsequent tick).
421                task.handle.abort();
422                stats.record_retry(&Aria2Error::Recoverable(RecoverableError::Timeout));
423            }
424        }
425        Ok(())
426    }
427
428    /// Reap all tasks that have finished or been aborted since the last tick.
429    ///
430    /// `try_join_next_with_id` is non-blocking: it returns `None` when no task
431    /// is ready, leaving in-flight tasks running. On abort/panic,
432    /// `JoinError::id()` recovers the task id so we can clean up our
433    /// bookkeeping even when the task did not return a value.
434    async fn collect_completed(
435        &self,
436        running: &mut JoinSet<Result<()>>,
437        running_tasks: &mut HashMap<Id, RunningTask>,
438    ) -> Result<()> {
439        while let Some(join_result) = running.try_join_next_with_id() {
440            match join_result {
441                Ok((id, Ok(()))) => {
442                    running_tasks.remove(&id);
443                    debug!("Command (task {}) completed successfully", id);
444                }
445                Ok((id, Err(e))) => {
446                    running_tasks.remove(&id);
447                    // v1: execute() failures are not auto-retried (the command
448                    // is owned by the task and has been dropped). Log and rely
449                    // on session auto-save for progress.
450                    error!("Command (task {}) execution failed: {}", id, e);
451                }
452                Err(join_err) => {
453                    let id = join_err.id();
454                    running_tasks.remove(&id);
455                    if join_err.is_cancelled() {
456                        // Cancelled by check_timeouts (timeout) or shutdown.
457                        debug!("Command (task {}) was cancelled", id);
458                    } else if join_err.is_panic() {
459                        error!("Command (task {}) panicked: {}", id, join_err);
460                    } else {
461                        warn!("Command (task {}) joined with error: {}", id, join_err);
462                    }
463                }
464            }
465        }
466        Ok(())
467    }
468
469    async fn shutdown(&self, running: &mut JoinSet<Result<()>>) {
470        info!("Shutting down running commands...");
471        // Persist session state before tearing down tasks so partial progress
472        // is not lost.
473        if let (Some(path), Some(man)) = (&self.save_session_path, &self.request_group_man) {
474            let mut cmd = SaveSessionCommand::new(path.clone(), man.clone());
475            match cmd.execute().await {
476                Ok(_) => info!("Session saved on shutdown to {}", path.display()),
477                Err(e) => warn!("Failed to save session on shutdown: {}", e),
478            }
479        }
480        // Abort every still-running command task.
481        running.abort_all();
482    }
483
484    pub async fn shutdown_engine(&mut self) -> Result<()> {
485        if let Some(tx) = self.shutdown_tx.take() {
486            let _ = tx.send(());
487        }
488        Ok(())
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use crate::engine::command::CommandStatus;
496    use async_trait::async_trait;
497    use std::sync::Arc;
498    use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
499    use std::time::{Duration, Instant};
500
501    /// A command that sleeps far longer than its reported timeout, so the
502    /// engine must abort it. Sets `completed` only if `execute()` runs to
503    /// completion; an abort cancels the sleep and leaves it `false`.
504    struct StalledCommand {
505        timeout_dur: Duration,
506        completed: Arc<AtomicBool>,
507    }
508
509    #[async_trait]
510    impl Command for StalledCommand {
511        async fn execute(&mut self) -> Result<()> {
512            // Sleep far beyond the test bound so only an abort can finish us.
513            tokio::time::sleep(Duration::from_secs(30)).await;
514            self.completed.store(true, Ordering::SeqCst);
515            Ok(())
516        }
517
518        fn status(&self) -> CommandStatus {
519            CommandStatus::Running
520        }
521
522        fn timeout(&self) -> Option<Duration> {
523            Some(self.timeout_dur)
524        }
525    }
526
527    /// Regression test for Task A1: the old `check_timeouts` awaited an EMPTY
528    /// future (`async {}`) that resolved instantly, so no command ever timed
529    /// out. This test verifies a stalled command is actually aborted once its
530    /// per-command timeout elapses, and that `RetryStats::timeouts()` is
531    /// incremented.
532    #[tokio::test]
533    async fn test_check_timeouts_actually_times_out_stalled_command() {
534        // 10ms tick so timeouts are detected promptly.
535        let engine = DownloadEngine::new(10);
536        // Clone the stats Arc before run() consumes the engine so we can
537        // inspect counters after the loop exits.
538        let stats = engine.retry_stats.clone();
539
540        let completed = Arc::new(AtomicBool::new(false));
541        let cmd = StalledCommand {
542            timeout_dur: Duration::from_millis(50),
543            completed: completed.clone(),
544        };
545        engine.add_command(Box::new(cmd)).unwrap();
546
547        // The engine exits once the stalled command is aborted and reaped
548        // (pending/running/failed all empty). Bound at 2s to fail loudly if it
549        // hangs (which would indicate the timeout was NOT enforced).
550        let run_result = tokio::time::timeout(Duration::from_secs(2), engine.run()).await;
551
552        assert!(
553            run_result.is_ok(),
554            "engine.run() should complete within 2s, not hang (timeout not enforced?)"
555        );
556        assert!(
557            stats.timeouts() >= 1,
558            "expected at least 1 recorded timeout, got {} (Task A1 bug: empty future)",
559            stats.timeouts()
560        );
561        assert!(
562            !completed.load(Ordering::SeqCst),
563            "stalled command should have been aborted before completing"
564        );
565    }
566
567    /// A command that sleeps for `delay` then increments a shared counter.
568    /// Used to verify the engine dispatches commands concurrently (Task A2).
569    struct SlowCommand {
570        delay: Duration,
571        completed: Arc<AtomicU32>,
572    }
573
574    #[async_trait]
575    impl Command for SlowCommand {
576        async fn execute(&mut self) -> Result<()> {
577            tokio::time::sleep(self.delay).await;
578            self.completed.fetch_add(1, Ordering::SeqCst);
579            Ok(())
580        }
581
582        fn status(&self) -> CommandStatus {
583            CommandStatus::Running
584        }
585    }
586
587    /// Regression test for Task A2: the old `dispatch_commands` awaited
588    /// `cmd.execute()` inline in the engine loop, serializing all commands.
589    /// With 5 commands x 100ms that would take ~500ms+. This test verifies
590    /// they run concurrently (well under 300ms).
591    #[tokio::test]
592    async fn test_engine_dispatches_commands_concurrently() {
593        // 10ms tick so dispatch happens promptly on the first tick.
594        let engine = DownloadEngine::new(10);
595        let completed = Arc::new(AtomicU32::new(0));
596        for _ in 0..5 {
597            let cmd = SlowCommand {
598                delay: Duration::from_millis(100),
599                completed: completed.clone(),
600            };
601            engine.add_command(Box::new(cmd)).unwrap();
602        }
603
604        let start = Instant::now();
605        let run_result = tokio::time::timeout(Duration::from_millis(500), engine.run()).await;
606        let elapsed = start.elapsed();
607
608        assert!(
609            run_result.is_ok(),
610            "engine.run() should complete within 500ms, not hang"
611        );
612        assert_eq!(
613            completed.load(Ordering::SeqCst),
614            5,
615            "all 5 commands should have completed"
616        );
617        // 5 commands x 100ms serialized = ~500ms + tick overhead. Concurrent
618        // execution should finish in ~100ms + a couple of ticks. A 300ms bound
619        // still proves concurrency beyond doubt while tolerating CI jitter.
620        assert!(
621            elapsed < Duration::from_millis(300),
622            "5x100ms commands took {:?}, indicating serialization (concurrent should be ~150ms)",
623            elapsed
624        );
625    }
626
627    // ==================== Progress channel (Task E3) tests ====================
628
629    use crate::engine::command::ProgressUpdate;
630    use crate::request::request_group::{DownloadOptions, GroupId, RequestGroup};
631
632    /// Helper: build a fresh `RequestGroup` wrapped in an `Arc<RwLock<..>>`,
633    /// the same shape `DownloadCommand` and the aggregator use.
634    fn make_group() -> Arc<RwLock<RequestGroup>> {
635        Arc::new(RwLock::new(RequestGroup::new(
636            GroupId::new(1),
637            vec!["http://example.com/file.bin".to_string()],
638            DownloadOptions::default(),
639        )))
640    }
641
642    /// Verify the aggregator receives `ProgressUpdate`s sent through the
643    /// channel and applies them to the `RequestGroup` (both the RwLock-backed
644    /// `completed_length` via `update_progress` and the atomic mirror via
645    /// `set_completed_length`).
646    #[tokio::test]
647    async fn test_progress_channel_updates_request_group() {
648        let group = make_group();
649        let (tx, rx) = mpsc::unbounded_channel::<ProgressUpdate>();
650
651        let group_clone = Arc::clone(&group);
652        let handle = DownloadEngine::spawn_progress_aggregator(group_clone, rx);
653
654        // Send a sequence of strictly increasing updates.
655        tx.send(ProgressUpdate {
656            completed_bytes: 1000,
657            download_speed: 0,
658            upload_speed: 0,
659        })
660        .unwrap();
661        tx.send(ProgressUpdate {
662            completed_bytes: 5000,
663            download_speed: 0,
664            upload_speed: 0,
665        })
666        .unwrap();
667
668        // Drop the sender: the aggregator drains all queued messages (unbounded
669        // channel recv only returns None after the queue is empty and all
670        // senders are gone), then exits. Awaiting the handle is therefore a
671        // deterministic synchronization point — no sleep-based polling needed.
672        drop(tx);
673        handle.await.expect("aggregator task should exit cleanly");
674
675        // The atomic mirror is set by `set_completed_length`; verify final value.
676        let atomic_val = { group.read().await.get_completed_length() };
677        assert_eq!(
678            atomic_val, 5000,
679            "aggregator should have applied the latest completed_bytes (5000)"
680        );
681    }
682
683    /// Verify the aggregator skips no-op updates with identical
684    /// `completed_bytes` (deduplication), so a flood of stale in-flight sends
685    /// does not cause redundant write-lock acquisitions.
686    #[tokio::test]
687    async fn test_progress_aggregator_dedupes_identical_bytes() {
688        let group = make_group();
689        let (tx, rx) = mpsc::unbounded_channel::<ProgressUpdate>();
690
691        let group_clone = Arc::clone(&group);
692        let handle = DownloadEngine::spawn_progress_aggregator(group_clone, rx);
693
694        // First real update.
695        tx.send(ProgressUpdate {
696            completed_bytes: 2048,
697            download_speed: 0,
698            upload_speed: 0,
699        })
700        .unwrap();
701        // Several duplicates with the same completed_bytes (and a speed that
702        // must NOT be applied because the dedup `continue`s before reaching
703        // the speed branch).
704        for _ in 0..5 {
705            tx.send(ProgressUpdate {
706                completed_bytes: 2048,
707                download_speed: 9999,
708                upload_speed: 0,
709            })
710            .unwrap();
711        }
712        // A real advance with a speed sample that SHOULD be applied.
713        tx.send(ProgressUpdate {
714            completed_bytes: 4096,
715            download_speed: 1234,
716            upload_speed: 0,
717        })
718        .unwrap();
719
720        // Deterministic drain: drop sender + await handle guarantees all
721        // queued messages have been processed by the aggregator.
722        drop(tx);
723        handle.await.expect("aggregator task should exit cleanly");
724
725        let g = group.read().await;
726        assert_eq!(
727            g.get_completed_length(),
728            4096,
729            "final completed_bytes should be 4096"
730        );
731        assert_eq!(
732            g.get_download_speed_cached(),
733            1234,
734            "speed from the advancing update should be cached"
735        );
736    }
737
738    /// Verify the aggregator only refreshes the speed fields when a non-zero
739    /// `download_speed` is provided (0 means "no fresh sample this tick" and
740    /// must not zero out a previously cached speed).
741    #[tokio::test]
742    async fn test_progress_aggregator_preserves_speed_on_zero_sample() {
743        let group = make_group();
744        let (tx, rx) = mpsc::unbounded_channel::<ProgressUpdate>();
745
746        // Seed the group with a known cached speed before starting the aggregator.
747        {
748            let g = group.read().await;
749            g.set_download_speed_cached(5555);
750        }
751
752        let group_clone = Arc::clone(&group);
753        let handle = DownloadEngine::spawn_progress_aggregator(group_clone, rx);
754
755        // Advance bytes but send a 0 speed sample (no fresh measurement).
756        tx.send(ProgressUpdate {
757            completed_bytes: 8192,
758            download_speed: 0,
759            upload_speed: 0,
760        })
761        .unwrap();
762
763        // Deterministic drain.
764        drop(tx);
765        handle.await.expect("aggregator task should exit cleanly");
766
767        let g = group.read().await;
768        assert_eq!(g.get_completed_length(), 8192, "bytes should advance");
769        assert_eq!(
770            g.get_download_speed_cached(),
771            5555,
772            "cached speed must be preserved when download_speed sample is 0"
773        );
774    }
775
776    /// Verify the aggregator task exits cleanly (JoinHandle resolves) once all
777    /// senders are dropped, with no hang or resource leak.
778    #[tokio::test]
779    async fn test_progress_aggregator_exits_on_sender_drop() {
780        let group = make_group();
781        let (tx, rx) = mpsc::unbounded_channel::<ProgressUpdate>();
782
783        let handle = DownloadEngine::spawn_progress_aggregator(group, rx);
784
785        // Drop the only sender; the aggregator's `recv().await` returns None.
786        drop(tx);
787
788        // The handle should resolve promptly without needing to abort.
789        let result = tokio::time::timeout(Duration::from_millis(500), handle).await;
790        assert!(
791            result.is_ok(),
792            "aggregator should exit within 500ms after senders are dropped"
793        );
794        result
795            .expect("aggregator task should exit cleanly")
796            .expect("aggregator task should not panic");
797    }
798}