Skip to main content

aria2_core/engine/
download_command.rs

1use async_trait::async_trait;
2use std::sync::Arc;
3use std::time::Duration;
4use tokio::io::AsyncReadExt;
5use tokio::sync::mpsc;
6use tracing::{debug, info, warn};
7
8use crate::checksum::checksum::Checksum;
9use crate::checksum::message_digest::HashType;
10use crate::constants;
11use crate::engine::active_output_registry::global_registry;
12use crate::engine::command::{Command, CommandStatus, ProgressUpdate};
13use crate::engine::concurrent_download::{ConcurrentDownloadResult, ConcurrentDownloader};
14use crate::engine::download_cookie::CookieHelper;
15use crate::engine::download_progress::ProgressUpdater;
16use crate::engine::range_prober::RangeProber;
17use crate::engine::retry_policy::RetryPolicy;
18use crate::engine::sequential_download::SequentialDownloader;
19use crate::error::{Aria2Error, Result};
20use crate::filesystem::file_allocation;
21use crate::filesystem::resume_helper::ResumeHelper;
22use crate::http::client_pool;
23use crate::http::cookie::Cookie;
24use crate::http::cookie_storage::CookieStorage;
25use crate::http::socks_connector::{NoProxyMatcher, ProxyUrl};
26use crate::request::request_group::{DownloadOptions, GroupId, RequestGroup};
27use crate::selector::server_stat_man::ServerStatMan;
28use crate::util::perf_monitor::{AtomicMetrics, Metrics, PerformanceMonitor};
29
30pub struct DownloadCommand {
31    group: Arc<tokio::sync::RwLock<RequestGroup>>,
32    client: Arc<reqwest::Client>,
33    output_path: std::path::PathBuf,
34    started: bool,
35    completed: bool,
36    completed_bytes: u64,
37    file_allocation: String,
38    mmap_threshold: u64,
39    secure_falloc: bool,
40    cookie_storage: Arc<CookieStorage>,
41    cookie_file: Option<String>,
42    no_proxy_matcher: Option<NoProxyMatcher>,
43    stat_man: Arc<ServerStatMan>,
44    perf_monitor: Option<Arc<PerformanceMonitor>>,
45    atomic_metrics: Arc<AtomicMetrics>,
46    headers: Vec<(String, String)>,
47    progress_sender: Option<mpsc::UnboundedSender<ProgressUpdate>>,
48    progress_receiver: Option<mpsc::UnboundedReceiver<ProgressUpdate>>,
49    progress_aggregator_handle: Option<tokio::task::JoinHandle<()>>,
50}
51
52impl DownloadCommand {
53    pub fn new(
54        gid: GroupId,
55        uri: &str,
56        options: &DownloadOptions,
57        output_dir: Option<&str>,
58        output_name: Option<&str>,
59    ) -> Result<Self> {
60        let group = Arc::new(tokio::sync::RwLock::new(RequestGroup::new(
61            gid,
62            vec![uri.to_string()],
63            options.clone(),
64        )));
65        Self::new_with_group(group, uri, options, output_dir, output_name)
66    }
67
68    pub fn new_with_group(
69        group: Arc<tokio::sync::RwLock<RequestGroup>>,
70        uri: &str,
71        options: &DownloadOptions,
72        output_dir: Option<&str>,
73        output_name: Option<&str>,
74    ) -> Result<Self> {
75        let dir = output_dir
76            .map(|d| d.to_string())
77            .or_else(|| options.dir.clone())
78            .unwrap_or_else(|| constants::DEFAULT_OUTPUT_DIR.to_string());
79
80        let filename = output_name
81            .map(|n| n.to_string())
82            .or_else(|| Self::extract_filename(uri))
83            .unwrap_or_else(|| constants::DEFAULT_FILENAME.to_string());
84
85        let path = std::path::PathBuf::from(&dir).join(&filename);
86        let headers = options.parsed_headers();
87
88        let no_proxy = options.http_proxy.is_none() && options.all_proxy.is_none();
89        let client = if no_proxy {
90            client_pool::get_global_client()
91        } else {
92            let mut builder = reqwest::Client::builder()
93                .connect_timeout(Duration::from_secs(
94                    constants::HTTP_DEFAULT_CONNECT_TIMEOUT_SECS,
95                ))
96                .timeout(Duration::from_secs(
97                    constants::HTTP_DEFAULT_OVERALL_TIMEOUT_SECS,
98                ))
99                .user_agent(constants::USER_AGENT)
100                .redirect(reqwest::redirect::Policy::limited(
101                    constants::HTTP_DEFAULT_MAX_REDIRECTS,
102                ))
103                .pool_max_idle_per_host(constants::HTTP_DEFAULT_POOL_MAX_IDLE_PER_HOST)
104                .pool_idle_timeout(Some(std::time::Duration::from_secs(
105                    constants::HTTP_DEFAULT_POOL_IDLE_TIMEOUT_SECS,
106                )))
107                .tcp_keepalive(Some(std::time::Duration::from_secs(
108                    constants::HTTP_DEFAULT_TCP_KEEPALIVE_SECS,
109                )));
110
111            if let Some(ref proxy) = options.http_proxy
112                && let Ok(proxy_url) = proxy.parse::<reqwest::Url>()
113                && let Ok(p) = reqwest::Proxy::all(proxy_url.to_string())
114            {
115                builder = builder.proxy(p);
116            }
117
118            if options.http_proxy.is_none()
119                && let Some(ref all_proxy) = options.all_proxy
120            {
121                match ProxyUrl::parse(all_proxy) {
122                    Ok(parsed) => match parsed.protocol {
123                        crate::http::socks_connector::ProxyProtocol::Http
124                        | crate::http::socks_connector::ProxyProtocol::Https => {
125                            if let Ok(p) = reqwest::Proxy::all(all_proxy.to_string()) {
126                                builder = builder.proxy(p);
127                            }
128                        }
129                        _ => {
130                            tracing::info!(
131                                "SOCKS proxy configured ({}) - use SocksConnector for direct TCP connections",
132                                all_proxy
133                            );
134                        }
135                    },
136                    Err(e) => {
137                        warn!("Failed to parse all-proxy URL '{}': {}", all_proxy, e);
138                    }
139                }
140            }
141
142            let client = builder.build().map_err(|e| {
143                Aria2Error::Fatal(crate::error::FatalError::Config(format!(
144                    "Failed to build HTTP client: {}",
145                    e
146                )))
147            })?;
148
149            Arc::new(client)
150        };
151
152        info!("DownloadCommand created: {} -> {}", uri, path.display());
153
154        let cookie_file = options.cookie_file.clone();
155        let cookie_storage = Arc::new(CookieStorage::new());
156
157        Self::load_cookies(&cookie_storage, &cookie_file, uri, options);
158
159        let (progress_tx, progress_rx) = mpsc::unbounded_channel::<ProgressUpdate>();
160
161        Ok(Self {
162            group,
163            client,
164            output_path: path,
165            started: false,
166            completed: false,
167            completed_bytes: 0,
168            file_allocation: options
169                .file_allocation
170                .clone()
171                .unwrap_or_else(|| constants::DEFAULT_FILE_ALLOCATION.to_string()),
172            mmap_threshold: options.mmap_threshold.unwrap_or(256 * 1024 * 1024),
173            secure_falloc: options.secure_falloc,
174            cookie_storage,
175            cookie_file,
176            no_proxy_matcher: options
177                .no_proxy
178                .as_ref()
179                .map(|np| NoProxyMatcher::from_env_value(np)),
180            stat_man: Arc::new(ServerStatMan::new()),
181            perf_monitor: None,
182            atomic_metrics: Arc::new(AtomicMetrics::new()),
183            headers,
184            progress_sender: Some(progress_tx),
185            progress_receiver: Some(progress_rx),
186            progress_aggregator_handle: None,
187        })
188    }
189
190    fn load_cookies(
191        cookie_storage: &Arc<CookieStorage>,
192        cookie_file: &Option<String>,
193        uri: &str,
194        options: &DownloadOptions,
195    ) {
196        if let Some(cf) = cookie_file {
197            let p = std::path::Path::new(cf);
198            if p.exists() {
199                match cookie_storage.load_file(p) {
200                    Ok(n) => info!("Loaded {} cookies from file: {}", n, cf),
201                    Err(e) => warn!("Failed to load cookie file {}: {}", cf, e),
202                }
203            }
204        }
205
206        if let Some(ref cookies_str) = options.cookies {
207            let domain = Self::extract_host(uri);
208            for pair in cookies_str.split(';') {
209                let pair = pair.trim();
210                if pair.is_empty() {
211                    continue;
212                }
213                if let Some((name, value)) = pair.split_once('=') {
214                    let name = name.trim();
215                    let value = value.trim();
216                    if !name.is_empty() {
217                        cookie_storage.add(Cookie::new(name, value, &domain));
218                    }
219                }
220            }
221            if !cookie_storage.is_empty() {
222                info!("Manually set {} cookies", cookie_storage.count());
223            }
224        }
225    }
226
227    pub fn new_with_client(
228        gid: GroupId,
229        uri: &str,
230        options: &DownloadOptions,
231        output_dir: Option<&str>,
232        output_name: Option<&str>,
233        client: Arc<reqwest::Client>,
234    ) -> Result<Self> {
235        let group = Arc::new(tokio::sync::RwLock::new(RequestGroup::new(
236            gid,
237            vec![uri.to_string()],
238            options.clone(),
239        )));
240        Self::new_with_group_and_client(group, uri, options, output_dir, output_name, client)
241    }
242
243    pub fn new_with_group_and_client(
244        group: Arc<tokio::sync::RwLock<RequestGroup>>,
245        uri: &str,
246        options: &DownloadOptions,
247        output_dir: Option<&str>,
248        output_name: Option<&str>,
249        client: Arc<reqwest::Client>,
250    ) -> Result<Self> {
251        let dir = output_dir
252            .map(|d| d.to_string())
253            .or_else(|| options.dir.clone())
254            .unwrap_or_else(|| constants::DEFAULT_OUTPUT_DIR.to_string());
255
256        let filename = output_name
257            .map(|n| n.to_string())
258            .or_else(|| Self::extract_filename(uri))
259            .unwrap_or_else(|| constants::DEFAULT_FILENAME.to_string());
260
261        let path = std::path::PathBuf::from(&dir).join(&filename);
262
263        let headers = options.parsed_headers();
264        info!(
265            "DownloadCommand created (shared client): {} -> {}",
266            uri,
267            path.display()
268        );
269
270        let cookie_file = options.cookie_file.clone();
271        let cookie_storage = Arc::new(CookieStorage::new());
272
273        Self::load_cookies(&cookie_storage, &cookie_file, uri, options);
274
275        let (progress_tx, progress_rx) = mpsc::unbounded_channel::<ProgressUpdate>();
276
277        Ok(Self {
278            group,
279            client,
280            output_path: path,
281            started: false,
282            completed: false,
283            completed_bytes: 0,
284            file_allocation: options
285                .file_allocation
286                .clone()
287                .unwrap_or_else(|| constants::DEFAULT_FILE_ALLOCATION.to_string()),
288            mmap_threshold: options.mmap_threshold.unwrap_or(256 * 1024 * 1024),
289            secure_falloc: options.secure_falloc,
290            cookie_storage,
291            cookie_file,
292            no_proxy_matcher: options
293                .no_proxy
294                .as_ref()
295                .map(|np| NoProxyMatcher::from_env_value(np)),
296            stat_man: Arc::new(ServerStatMan::new()),
297            perf_monitor: None,
298            atomic_metrics: Arc::new(AtomicMetrics::new()),
299            headers,
300            progress_sender: Some(progress_tx),
301            progress_receiver: Some(progress_rx),
302            progress_aggregator_handle: None,
303        })
304    }
305
306    pub fn new_with_stat_man(
307        gid: GroupId,
308        uri: &str,
309        options: &DownloadOptions,
310        output_dir: Option<&str>,
311        output_name: Option<&str>,
312        stat_man: Arc<ServerStatMan>,
313    ) -> Result<Self> {
314        let mut cmd = Self::new(gid, uri, options, output_dir, output_name)?;
315        cmd.stat_man = stat_man;
316        Ok(cmd)
317    }
318
319    pub fn enable_perf_monitor(&mut self) {
320        self.perf_monitor = Some(Arc::new(PerformanceMonitor::new()));
321    }
322
323    #[allow(dead_code)]
324    pub(crate) fn with_progress_sender(
325        mut self,
326        sender: mpsc::UnboundedSender<ProgressUpdate>,
327    ) -> Self {
328        self.progress_sender = Some(sender);
329        self.progress_receiver = None;
330        self
331    }
332
333    pub(crate) fn spawn_progress_aggregator(&mut self) {
334        if self.progress_aggregator_handle.is_some() {
335            return;
336        }
337        if let Some(rx) = self.progress_receiver.take() {
338            let handle = crate::engine::download_engine::DownloadEngine::spawn_progress_aggregator(
339                Arc::clone(&self.group),
340                rx,
341            );
342            self.progress_aggregator_handle = Some(handle);
343        }
344    }
345
346    pub(crate) async fn drain_progress_aggregator(&mut self) {
347        self.progress_sender = None;
348        if let Some(handle) = self.progress_aggregator_handle.take()
349            && let Err(e) = handle.await
350        {
351            warn!("Progress aggregator task ended unexpectedly: {}", e);
352        }
353    }
354
355    pub fn get_perf_metrics(&self) -> Metrics {
356        self.atomic_metrics.snapshot()
357    }
358
359    pub fn get_perf_report(&self) -> Option<String> {
360        self.perf_monitor.as_ref().map(|m| m.export_text())
361    }
362
363    pub fn get_perf_report_json(&self) -> Option<String> {
364        self.perf_monitor.as_ref().map(|m| m.export_json())
365    }
366
367    fn extract_filename(uri: &str) -> Option<String> {
368        uri.rsplit('/')
369            .next()
370            .filter(|s| !s.is_empty() && *s != "/")
371            .map(|s| s.split('?').next().unwrap_or(s).to_string())
372    }
373
374    fn extract_host(uri: &str) -> String {
375        reqwest::Url::parse(uri)
376            .ok()
377            .and_then(|u| u.host_str().map(|h| h.to_string()))
378            .unwrap_or_else(|| constants::DEFAULT_HOST.to_string())
379    }
380
381    pub async fn group(&self) -> tokio::sync::RwLockReadGuard<'_, RequestGroup> {
382        self.group.read().await
383    }
384
385    pub async fn group_mut(&self) -> tokio::sync::RwLockWriteGuard<'_, RequestGroup> {
386        self.group.write().await
387    }
388
389    pub fn no_proxy_matcher(&self) -> Option<&NoProxyMatcher> {
390        self.no_proxy_matcher.as_ref()
391    }
392
393    fn should_use_concurrent(&self, total_length: u64, supports_range: bool, split: u16) -> bool {
394        if !supports_range {
395            return false;
396        }
397        if total_length < constants::CONCURRENT_MIN_FILE_SIZE as u64 {
398            return false;
399        }
400        split > 1
401    }
402
403    fn create_cookie_helper(&self) -> CookieHelper {
404        CookieHelper::new(Arc::clone(&self.cookie_storage), self.cookie_file.clone())
405    }
406
407    fn create_progress_updater(&self) -> ProgressUpdater {
408        ProgressUpdater::new(
409            self.progress_sender.clone(),
410            Arc::clone(&self.group),
411            Arc::clone(&self.atomic_metrics),
412            self.perf_monitor.clone(),
413        )
414    }
415}
416
417#[async_trait]
418impl Command for DownloadCommand {
419    async fn execute(&mut self) -> Result<()> {
420        if !self.started {
421            self.group.write().await.start().await?;
422            self.started = true;
423        }
424
425        let uri = {
426            let g = self.group.read().await;
427            g.uris().first().cloned().unwrap_or_default()
428        };
429
430        if uri.is_empty() {
431            return Err(Aria2Error::Fatal(crate::error::FatalError::Config(
432                "Download URI is empty".into(),
433            )));
434        }
435
436        debug!(
437            "Starting download: {} -> {}",
438            uri,
439            self.output_path.display()
440        );
441
442        if let Some(parent) = self.output_path.parent()
443            && !parent.exists()
444        {
445            std::fs::create_dir_all(parent).map_err(|e| {
446                Aria2Error::Fatal(crate::error::FatalError::Config(format!(
447                    "Failed to create directory: {}",
448                    e
449                )))
450            })?;
451        }
452
453        let original_path = self.output_path.clone();
454        self.output_path = global_registry().resolve(&original_path).await;
455        if self.output_path != original_path {
456            info!(
457                "Filename collision resolved: '{}' -> '{}'",
458                original_path.display(),
459                self.output_path.display()
460            );
461        }
462
463        let release_path = |path: &std::path::Path| {
464            let p = path.to_path_buf();
465            #[allow(clippy::let_underscore_future)]
466            let _ = tokio::spawn(async move {
467                global_registry().release(&p).await;
468            });
469        };
470
471        let url_for_head = reqwest::Url::parse(&uri).ok();
472        let cookie_hdr_head = if let Some(ref url) = url_for_head {
473            CookieHelper::new(Arc::clone(&self.cookie_storage), self.cookie_file.clone())
474                .build_cookie_header_from_url(url)
475        } else {
476            String::new()
477        };
478        let mut head_req = self.client.head(&uri);
479        if !cookie_hdr_head.is_empty() {
480            head_req = head_req.header("Cookie", &cookie_hdr_head);
481        }
482        for (name, value) in &self.headers {
483            head_req = head_req.header(name, value);
484        }
485        let head_resp = head_req.send().await.ok();
486        let (total_length, head_supports_range) = if let Some(ref resp) = head_resp {
487            let tl = resp
488                .headers()
489                .get(reqwest::header::CONTENT_LENGTH)
490                .and_then(|v| v.to_str().ok())
491                .and_then(|v| v.parse::<u64>().ok())
492                .unwrap_or(0);
493            let sr = resp
494                .headers()
495                .get("Accept-Ranges")
496                .and_then(|v| v.to_str().ok())
497                .is_some_and(|v| v.to_lowercase().contains("bytes"));
498            (tl, sr)
499        } else {
500            (0, false)
501        };
502
503        let supports_range = if head_supports_range {
504            true
505        } else if total_length > constants::CONCURRENT_MIN_FILE_SIZE as u64 {
506            let prober = RangeProber::new(Arc::clone(&self.client), self.headers.clone());
507            prober.probe_range_support(&uri, total_length).await
508        } else {
509            false
510        };
511
512        let resume_helper = ResumeHelper::new(&self.output_path, true);
513        let resume_state = resume_helper.detect(total_length).await?;
514
515        if resume_state.is_complete {
516            info!(
517                "File already exists completely, skipping download: {} ({} bytes)",
518                self.output_path.display(),
519                resume_state.existing_length
520            );
521            self.completed_bytes = resume_state.existing_length;
522            let mut g = self.group.write().await;
523            g.set_total_length(self.completed_bytes).await;
524            g.update_progress(self.completed_bytes).await;
525            g.set_total_length_atomic(self.completed_bytes);
526            g.set_completed_length(self.completed_bytes);
527            g.complete().await?;
528            self.completed = true;
529            release_path(&self.output_path);
530            return Ok(());
531        }
532
533        self.spawn_progress_aggregator();
534
535        let download_result: Result<()> = async {
536            if total_length > 0 {
537                file_allocation::preallocate_file(
538                    &self.output_path,
539                    total_length,
540                    &self.file_allocation,
541                    self.secure_falloc,
542                )
543                .await?;
544            }
545
546            let options = self.group.read().await.options().clone();
547            let split = options.split.unwrap_or(constants::DEFAULT_SPLIT);
548
549            let cookie_helper = self.create_cookie_helper();
550            let progress_updater = self.create_progress_updater();
551
552            if self.should_use_concurrent(total_length, supports_range, split) {
553                if resume_state.should_resume {
554                    info!(
555                        "Concurrent mode + resume: existing {} bytes, continuing from offset {}",
556                        resume_state.existing_length, resume_state.start_offset
557                    );
558                }
559                let max_retries = options.max_retries;
560                let mut concurrent_downloader = ConcurrentDownloader::new(
561                    Arc::clone(&self.client),
562                    self.output_path.clone(),
563                    self.headers.clone(),
564                    cookie_helper.clone(),
565                    progress_updater.clone(),
566                    Arc::clone(&self.group),
567                    self.mmap_threshold,
568                    self.file_allocation.clone(),
569                );
570                match concurrent_downloader.execute_with_retry(
571                    &uri,
572                    total_length,
573                    &resume_state,
574                    max_retries,
575                ).await {
576                    Ok(ConcurrentDownloadResult::Complete) => return Ok(()),
577                    Ok(ConcurrentDownloadResult::Fallback { completed_ranges }) => {
578                        warn!(
579                            "Concurrent download falling back to sequential mode, preserving {} completed ranges",
580                            completed_ranges.len()
581                        );
582                        let retry_policy = RetryPolicy::new(options.max_retries, options.retry_wait * 1000);
583                        let mut sequential_downloader = SequentialDownloader::new(
584                            Arc::clone(&self.client),
585                            self.output_path.clone(),
586                            self.headers.clone(),
587                            cookie_helper,
588                            progress_updater,
589                            Arc::clone(&self.group),
590                        );
591                        return sequential_downloader.execute_with_gaps_with_retry(
592                            &uri,
593                            total_length,
594                            &completed_ranges,
595                            &retry_policy,
596                        ).await;
597                    }
598                    Err(e) => return Err(e),
599                }
600            }
601
602            let retry_policy = RetryPolicy::new(options.max_retries, options.retry_wait * 1000);
603            let mut sequential_downloader = SequentialDownloader::new(
604                Arc::clone(&self.client),
605                self.output_path.clone(),
606                self.headers.clone(),
607                cookie_helper,
608                progress_updater,
609                Arc::clone(&self.group),
610            );
611            sequential_downloader.execute_with_retry(
612                &uri,
613                &resume_state,
614                total_length,
615                &retry_policy,
616            ).await
617        }
618        .await;
619
620        self.drain_progress_aggregator().await;
621
622        if download_result.is_ok() {
623            // Verify checksum if configured
624            {
625                let g = self.group.read().await;
626                if let Some((ref algo, ref expected)) = g.options().checksum
627                    && let Some(ht) = HashType::from_str(algo) {
628                        let cs = Checksum::new(ht, expected)?;
629                        let file = tokio::fs::File::open(&self.output_path).await
630                            .map_err(|e| Aria2Error::Io(format!("Failed to open file for checksum verification: {}", e)))?;
631                        let mut reader = tokio::io::BufReader::with_capacity(65536, file);
632                        let mut validator = cs.create_validator();
633                        let mut buf = vec![0u8; 65536];
634                        loop {
635                            let n = reader.read(&mut buf).await
636                                .map_err(|e| Aria2Error::Io(format!("Read error during checksum verification: {}", e)))?;
637                            if n == 0 { break; }
638                            validator.update(&buf[..n]);
639                        }
640                        if !validator.finalize()? {
641                            tracing::error!(
642                                algo = %algo,
643                                path = %self.output_path.display(),
644                                "Checksum mismatch"
645                            );
646                            return Err(Aria2Error::Checksum(
647                                format!("{} checksum mismatch for {}", algo, self.output_path.display())
648                            ));
649                        }
650                        tracing::info!(
651                            algo = %algo,
652                            path = %self.output_path.display(),
653                            "Checksum verified successfully"
654                        );
655                    }
656            }
657            self.completed = true;
658            let g = self.group.write().await;
659            let total = g.total_length();
660            g.update_progress(total).await;
661            g.set_completed_length(total);
662        }
663        release_path(&self.output_path);
664        download_result
665    }
666
667    fn status(&self) -> CommandStatus {
668        if self.completed {
669            CommandStatus::Completed
670        } else if self.completed_bytes > 0 {
671            CommandStatus::Running
672        } else {
673            CommandStatus::Pending
674        }
675    }
676
677    fn timeout(&self) -> Option<Duration> {
678        Some(Duration::from_secs(
679            constants::HTTP_DEFAULT_COMMAND_TIMEOUT_SECS,
680        ))
681    }
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687    use crate::request::request_group::{DownloadOptions, GroupId, RequestGroup};
688
689    impl DownloadCommand {
690        fn has_progress_sender(&self) -> bool {
691            self.progress_sender.is_some()
692        }
693
694        fn has_progress_receiver(&self) -> bool {
695            self.progress_receiver.is_some()
696        }
697
698        fn has_progress_aggregator_handle(&self) -> bool {
699            self.progress_aggregator_handle.is_some()
700        }
701
702        fn send_progress_update(&self, update: ProgressUpdate) {
703            if let Some(ref sender) = self.progress_sender {
704                let _ = sender.send(update);
705            } else {
706                panic!("test called send_progress_update but no sender is set");
707            }
708        }
709    }
710
711    #[test]
712    fn test_progress_channel_auto_created() {
713        let cmd = DownloadCommand::new(
714            GroupId::new(1),
715            "http://example.com/file.bin",
716            &DownloadOptions::default(),
717            None,
718            None,
719        )
720        .expect("DownloadCommand::new should succeed with a valid HTTP URI");
721
722        assert!(
723            cmd.has_progress_sender(),
724            "progress_sender should be Some after construction (auto-created)"
725        );
726        assert!(
727            cmd.has_progress_receiver(),
728            "progress_receiver should be Some after construction (held for lazy spawn)"
729        );
730        assert!(
731            !cmd.has_progress_aggregator_handle(),
732            "progress_aggregator_handle should be None until execute() spawns it"
733        );
734    }
735
736    #[tokio::test]
737    async fn test_progress_updates_flow_through_channel() {
738        let group = Arc::new(tokio::sync::RwLock::new(RequestGroup::new(
739            GroupId::new(2),
740            vec!["http://example.com/file.bin".to_string()],
741            DownloadOptions::default(),
742        )));
743        let group_clone = Arc::clone(&group);
744
745        let mut cmd = DownloadCommand::new_with_group(
746            group,
747            "http://example.com/file.bin",
748            &DownloadOptions::default(),
749            None,
750            None,
751        )
752        .expect("DownloadCommand::new_with_group should succeed");
753
754        assert!(cmd.has_progress_sender());
755        assert!(cmd.has_progress_receiver());
756
757        cmd.spawn_progress_aggregator();
758        assert!(cmd.has_progress_aggregator_handle());
759        assert!(!cmd.has_progress_receiver());
760
761        cmd.send_progress_update(ProgressUpdate {
762            completed_bytes: 4096,
763            download_speed: 0,
764            upload_speed: 0,
765        });
766
767        cmd.drain_progress_aggregator().await;
768        assert!(!cmd.has_progress_sender());
769        assert!(!cmd.has_progress_aggregator_handle());
770
771        let completed = { group_clone.read().await.get_completed_length() };
772        assert_eq!(
773            completed, 4096,
774            "aggregator should have applied the progress update to RequestGroup"
775        );
776    }
777}