Skip to main content

aria2_core/engine/
http_segment_downloader.rs

1use dashmap::DashMap;
2use futures::StreamExt;
3use std::sync::atomic::{AtomicUsize, Ordering};
4use std::time::Duration;
5use tracing::{debug, warn};
6
7use crate::constants;
8use crate::error::{Aria2Error, RecoverableError, Result};
9// Re-export score_source for convenience
10pub use crate::selector::adaptive_uri_selector::{
11    score_source_raw as score_source, score_source_raw,
12};
13
14pub struct HttpSegmentDownloader {
15    client: reqwest::Client,
16}
17
18/// Calculate optimal segment size based on download speed and remaining data.
19/// Returns size in bytes (between MIN_SEGMENT_SIZE and MAX_SEGMENT_SIZE).
20pub fn calculate_dynamic_segment_size(
21    total_remaining: u64,
22    num_connections: usize,
23    avg_speed_bps: f64,
24    elapsed_secs: u64,
25) -> u64 {
26    const MIN_SEGMENT: u64 = 1024 * 256; // 256 KB
27    const MAX_SEGMENT: u64 = 1024 * 1024 * 16; // 16 MB
28
29    if elapsed_secs < 2 || avg_speed_bps < 1024.0 {
30        // Too early or too slow — use conservative default
31        return (total_remaining / num_connections.max(1) as u64).clamp(MIN_SEGMENT, MAX_SEGMENT);
32    }
33
34    // Target ~10 seconds per segment at current speed
35    let target_size = (avg_speed_bps * 10.0) as u64;
36    target_size.clamp(MIN_SEGMENT, MAX_SEGMENT)
37}
38
39/// Track active connections per hostname to enforce max-connection-per-server limit.
40///
41/// Uses `DashMap` + `AtomicUsize` for interior mutability, so every method takes
42/// `&self` and the limiter can be shared via `Arc<ConnectionLimiter>` without any
43/// wrapping `Mutex`/`RwLock`. This is a *soft* limiter: the CAS-with-rollback
44/// pattern in [`ConnectionLimiter::try_acquire`] keeps the global and per-host
45/// counts bounded by their limits at the moment of each successful CAS, but
46/// snapshot readers (e.g. [`ConnectionLimiter::host_count`]) may observe
47/// briefly-stale values.
48pub struct ConnectionLimiter {
49    per_host: DashMap<String, AtomicUsize>,
50    global_count: AtomicUsize,
51    global_limit: usize,
52    per_host_limit: usize,
53}
54
55impl ConnectionLimiter {
56    /// Create a new `ConnectionLimiter` with the given global and per-host limits.
57    pub fn new(global: usize, per_host: usize) -> Self {
58        Self {
59            per_host: DashMap::new(),
60            global_count: AtomicUsize::new(0),
61            global_limit: global,
62            per_host_limit: per_host,
63        }
64    }
65
66    /// Try to acquire a connection slot for the given host.
67    ///
68    /// Returns `true` if the slot was acquired, `false` if either the global or
69    /// the per-host limit has been reached.
70    ///
71    /// # Algorithm (CAS-with-rollback)
72    ///
73    /// 1. Atomically increment `global_count` via CAS; abort if at/above limit.
74    /// 2. Atomically increment the per-host counter via CAS; if the per-host
75    ///    limit is hit, roll back the global increment so `global_count` stays
76    ///    accurate.
77    ///
78    /// This guarantees `global_count` never exceeds `global_limit` at the
79    /// moment of a successful CAS, and each host's counter never exceeds
80    /// `per_host_limit`. A brief shard-level write lock is held by the
81    /// `DashMap` `Entry` guard during the per-host CAS — acceptable for a
82    /// connection limiter which is not a hot path.
83    pub fn try_acquire(&self, host: &str) -> bool {
84        // Step 1: Atomically acquire a global slot via CAS. Only threads that
85        // observe `current < global_limit` can succeed, so the global count can
86        // never exceed `global_limit` at the instant of a successful CAS.
87        loop {
88            let current = self.global_count.load(Ordering::Relaxed);
89            if current >= self.global_limit {
90                return false;
91            }
92            match self.global_count.compare_exchange_weak(
93                current,
94                current + 1,
95                Ordering::AcqRel,
96                Ordering::Relaxed,
97            ) {
98                Ok(_) => break,
99                Err(_) => continue,
100            }
101        }
102
103        // Step 2: Try to acquire a per-host slot. On failure, roll back the
104        // global increment performed above so the global count stays accurate.
105        // The rollback uses Relaxed ordering: it only needs to eventually
106        // correct the over-count introduced in step 1, which was already
107        // published with AcqRel. A short-lived false-positive on the global
108        // limit (another thread briefly sees the inflated count) is acceptable
109        // for a soft limiter.
110        let entry = self
111            .per_host
112            .entry(host.to_string())
113            .or_insert_with(|| AtomicUsize::new(0));
114        loop {
115            let current = entry.load(Ordering::Relaxed);
116            if current >= self.per_host_limit {
117                // Per-host limit reached — roll back the global increment.
118                self.global_count.fetch_sub(1, Ordering::Relaxed);
119                return false;
120            }
121            match entry.compare_exchange_weak(
122                current,
123                current + 1,
124                Ordering::AcqRel,
125                Ordering::Relaxed,
126            ) {
127                Ok(_) => return true,
128                Err(_) => continue,
129            }
130        }
131    }
132
133    /// Release a previously-acquired connection slot for the given host.
134    ///
135    /// The caller must call this exactly once per successful `try_acquire` for
136    /// the same host. Double-releases are detected via `debug_assert!` in debug
137    /// builds.
138    pub fn release(&self, host: &str) {
139        if let Some(entry) = self.per_host.get(host) {
140            let prev = entry.fetch_sub(1, Ordering::AcqRel);
141            debug_assert!(prev > 0, "release called more times than acquire for host");
142        }
143        let prev = self.global_count.fetch_sub(1, Ordering::AcqRel);
144        debug_assert!(prev > 0, "global release underflow");
145    }
146
147    /// Current connection count for a host (snapshot — may be slightly stale).
148    pub fn host_count(&self, host: &str) -> usize {
149        self.per_host
150            .get(host)
151            .map(|e| e.load(Ordering::Relaxed))
152            .unwrap_or(0)
153    }
154
155    /// Total connection count across all hosts (snapshot — may be slightly stale).
156    pub fn global_count(&self) -> usize {
157        self.global_count.load(Ordering::Relaxed)
158    }
159
160    /// The configured global connection limit.
161    pub fn global_limit(&self) -> usize {
162        self.global_limit
163    }
164
165    /// The configured per-host connection limit.
166    pub fn per_host_limit(&self) -> usize {
167        self.per_host_limit
168    }
169
170    /// How many slots are still available for the given host (snapshot).
171    ///
172    /// Returns 0 if the host is at or above its per-host limit.
173    pub fn available_for(&self, host: &str) -> usize {
174        let current = self.host_count(host);
175        if current >= self.per_host_limit {
176            return 0;
177        }
178        self.per_host_limit - current
179    }
180}
181
182impl HttpSegmentDownloader {
183    /// Create a new `HttpSegmentDownloader`.
184    pub fn new(client: &reqwest::Client) -> Self {
185        Self {
186            client: client.clone(),
187        }
188    }
189
190    pub async fn supports_range(
191        &self,
192        url: &str,
193        cookie_header: Option<&str>,
194        headers: &[(String, String)],
195    ) -> Result<bool> {
196        let mut req = self.client.head(url);
197        if let Some(ch) = cookie_header {
198            req = req.header("Cookie", ch);
199        }
200        for (name, value) in headers {
201            req = req.header(name, value);
202        }
203        let resp = req.send().await.map_err(|e| {
204            Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
205                message: format!("HEAD request failed: {}", e),
206            })
207        })?;
208
209        if let Some(accept_ranges) = resp.headers().get("Accept-Ranges")
210            && let Ok(value) = accept_ranges.to_str()
211        {
212            return Ok(value.to_lowercase().contains("bytes"));
213        }
214
215        let status = resp.status();
216        if status.as_u16() >= 400 {
217            return Err(Aria2Error::Recoverable(RecoverableError::ServerError {
218                code: status.as_u16(),
219            }));
220        }
221
222        Ok(false)
223    }
224
225    pub async fn download_range(
226        &self,
227        url: &str,
228        offset: u64,
229        length: u64,
230        cookie_header: Option<&str>,
231        headers: &[(String, String)],
232    ) -> Result<bytes::Bytes> {
233        if length == 0 {
234            return Ok(bytes::Bytes::new());
235        }
236
237        let range_header = format!("bytes={}-{}", offset, offset + length.saturating_sub(1));
238        debug!("HTTP Range request: {} ({})", range_header, url);
239
240        let mut req =
241            self.client
242                .get(url)
243                .header("Range", &range_header)
244                .timeout(Duration::from_secs(
245                    constants::HTTP_DEFAULT_OVERALL_TIMEOUT_SECS,
246                ));
247        if let Some(ch) = cookie_header {
248            req = req.header("Cookie", ch);
249        }
250        for (name, value) in headers {
251            req = req.header(name, value);
252        }
253        let response = req.send().await.map_err(|e| {
254            Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
255                message: format!("HTTP Range request failed: {}", e),
256            })
257        })?;
258
259        let status = response.status();
260        match status.as_u16() {
261            206 => {}
262            200 => {
263                warn!(
264                    "Server returned 200 instead of 206 for Range request (offset={}, len={}), reading full body",
265                    offset, length
266                );
267            }
268            416 => {
269                return Err(Aria2Error::Recoverable(
270                    RecoverableError::TemporaryNetworkFailure {
271                        message: format!(
272                            "Range not satisfiable: bytes={}-{}",
273                            offset,
274                            offset + length.saturating_sub(1)
275                        ),
276                    },
277                ));
278            }
279            code if (400..500).contains(&code) => {
280                return Err(Aria2Error::Fatal(crate::error::FatalError::Config(
281                    format!("HTTP client error {}: {}", code, url),
282                )));
283            }
284            code if code >= 500 => {
285                return Err(Aria2Error::Recoverable(RecoverableError::ServerError {
286                    code,
287                }));
288            }
289            _ => {}
290        }
291
292        // Use BytesMut for efficient stream accumulation
293        let mut data = bytes::BytesMut::with_capacity(length as usize);
294        let mut stream = response.bytes_stream();
295
296        while let Some(chunk_result) = stream.next().await {
297            match chunk_result {
298                Ok(bytes) => data.extend_from_slice(&bytes),
299                Err(e) => {
300                    return Err(Aria2Error::Recoverable(
301                        RecoverableError::TemporaryNetworkFailure {
302                            message: format!("Stream read error: {}", e),
303                        },
304                    ));
305                }
306            }
307        }
308
309        if data.is_empty() && length > 0 {
310            return Err(Aria2Error::Recoverable(
311                RecoverableError::TemporaryNetworkFailure {
312                    message: format!(
313                        "Empty response for range {}-{} from {}",
314                        offset,
315                        offset + length.saturating_sub(1),
316                        url
317                    ),
318                },
319            ));
320        }
321
322        // Freeze BytesMut to immutable Bytes (zero-cost conversion)
323        Ok(data.freeze())
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[tokio::test]
332    async fn test_supports_range_no_server() {
333        let client = reqwest::Client::builder()
334            .connect_timeout(Duration::from_millis(100))
335            .build()
336            .unwrap();
337        let dl = HttpSegmentDownloader::new(&client);
338        let result = dl
339            .supports_range("http://127.0.0.1:1/nonexistent", None, &[])
340            .await;
341        assert!(result.is_err(), "should fail for unreachable host");
342    }
343
344    #[tokio::test]
345    async fn test_download_range_zero_length() {
346        let client = reqwest::Client::new();
347        let dl = HttpSegmentDownloader::new(&client);
348        let result = dl
349            .download_range("http://example.com", 0, 0, None, &[])
350            .await;
351        assert!(result.is_ok(), "zero-length range should return empty vec");
352        assert!(result.unwrap().is_empty());
353    }
354
355    #[tokio::test]
356    async fn test_downloader_creation() {
357        let client = reqwest::Client::new();
358        let dl = HttpSegmentDownloader::new(&client);
359        let _dl2 = HttpSegmentDownloader::new(&dl.client);
360    }
361
362    #[tokio::test]
363    async fn test_download_range_with_mock_http_416() {
364        use tokio::io::{AsyncReadExt, AsyncWriteExt};
365
366        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
367        let addr = listener.local_addr().unwrap();
368
369        let server_handle = tokio::spawn(async move {
370            let (mut stream, _) = listener.accept().await.unwrap();
371            let mut buf = [0u8; 2048];
372            // Use read() instead of read_exact() to avoid blocking on exact byte count
373            let _n = stream.read(&mut buf).await.unwrap();
374            stream.write_all(b"HTTP/1.1 416 Range Not Satisfiable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await.unwrap();
375        });
376
377        tokio::time::sleep(Duration::from_millis(50)).await;
378
379        let url = format!("http://{}", addr);
380        let client = reqwest::Client::builder()
381            .redirect(reqwest::redirect::Policy::none())
382            .timeout(Duration::from_secs(5))
383            .build()
384            .unwrap();
385        let dl = HttpSegmentDownloader::new(&client);
386
387        let result = dl.download_range(&url, 99999, 100, None, &[]).await;
388        assert!(result.is_err(), "416 should be an error");
389
390        // Wait for server with timeout
391        let _ = tokio::time::timeout(Duration::from_secs(2), server_handle).await;
392    }
393
394    #[tokio::test]
395    async fn test_supports_range_header_parsing() {
396        let client = reqwest::Client::builder()
397            .timeout(std::time::Duration::from_secs(3))
398            .build()
399            .unwrap();
400        let dl = HttpSegmentDownloader::new(&client);
401
402        match dl
403            .supports_range(
404                "http://invalid-host-name-that-does-not-exist-12345.com/",
405                None,
406                &[],
407            )
408            .await
409        {
410            Ok(supports) => {
411                eprintln!(
412                    "[WARN] Unexpected success for invalid host, supports={:?}",
413                    supports
414                );
415            }
416            Err(e) => {
417                println!("Expected network error for invalid host: {:?}", e);
418            }
419        }
420    }
421
422    #[tokio::test]
423    async fn test_download_range_status_code_handling() {
424        let client = reqwest::Client::new();
425        let dl = HttpSegmentDownloader::new(&client);
426
427        let result_404 = dl
428            .download_range("http://httpbin.org/status/404", 0, 100, None, &[])
429            .await;
430        assert!(result_404.is_err(), "404 should be fatal error");
431    }
432
433    #[test]
434    fn test_dynamic_segment_size_slow_start() {
435        // Early download (elapsed < 2 seconds) should use conservative default
436        let size = calculate_dynamic_segment_size(10_000_000, 4, 50000.0, 1);
437        // With 10MB remaining and 4 connections: 10_000_000 / 4 = 2.5MB = 2621440 bytes
438        // Should be clamped between MIN_SEGMENT (256KB) and MAX_SEGMENT (16MB)
439        assert!(size >= 1024 * 256, "Should be at least MIN_SEGMENT");
440        assert!(size <= 1024 * 1024 * 16, "Should be at most MAX_SEGMENT");
441
442        // Very slow speed (< 1KB/s) should also use conservative default
443        let size_slow = calculate_dynamic_segment_size(10_000_000, 4, 100.0, 5);
444        assert!(
445            size_slow >= 1024 * 256,
446            "Slow speed should use conservative default"
447        );
448    }
449
450    #[test]
451    fn test_dynamic_segment_size_fast_download() {
452        // Fast download (1 MB/s = 1048576 B/s) with sufficient elapsed time
453        let size = calculate_dynamic_segment_size(100_000_000, 8, 1_048_576.0, 10);
454        // Target size = 1048576.0 * 10.0 = 10485760 bytes (~10 MB)
455        // Should be clamped to MAX_SEGMENT if needed
456        assert_eq!(
457            size, 10_485_760,
458            "Fast download should produce large segments"
459        );
460
461        // Very fast download (10 MB/s)
462        let size_very_fast = calculate_dynamic_segment_size(1_000_000_000, 16, 10_485_760.0, 30);
463        // Target = 104857600 bytes (~100 MB), but capped at MAX_SEGMENT (16 MB)
464        assert_eq!(
465            size_very_fast, 16_777_216,
466            "Very fast download should be capped at MAX_SEGMENT"
467        );
468    }
469
470    #[test]
471    fn test_connection_limiter_per_host() {
472        let limiter = ConnectionLimiter::new(10, 2); // Global limit 10, per-host limit 2
473
474        // Should be able to acquire up to per_host_limit
475        assert!(
476            limiter.try_acquire("example.com"),
477            "First acquisition should succeed"
478        );
479        assert!(
480            limiter.try_acquire("example.com"),
481            "Second acquisition should succeed"
482        );
483        assert!(
484            !limiter.try_acquire("example.com"),
485            "Third acquisition should fail (per-host limit)"
486        );
487
488        // Different host should work independently
489        assert!(
490            limiter.try_acquire("other.com"),
491            "Different host should work"
492        );
493        assert!(
494            limiter.try_acquire("other.com"),
495            "Second slot for other host"
496        );
497        assert!(
498            !limiter.try_acquire("other.com"),
499            "Third slot for other host should fail"
500        );
501
502        // Release a slot
503        limiter.release("example.com");
504        assert!(
505            limiter.try_acquire("example.com"),
506            "After release, should acquire again"
507        );
508
509        // Check available slots
510        assert_eq!(
511            limiter.available_for("example.com"),
512            0,
513            "No slots available after acquiring limit"
514        );
515        limiter.release("example.com");
516        assert_eq!(
517            limiter.available_for("example.com"),
518            1,
519            "One slot available after release"
520        );
521    }
522
523    /// Verify the limiter is safe under concurrency: 20 tasks contend on a
524    /// single host whose per-host limit is 10, so exactly 10 must succeed and
525    /// the limiter must not deadlock. Also verifies `release` correctness.
526    #[tokio::test]
527    async fn test_connection_limiter_concurrent_no_deadlock() {
528        use std::sync::Arc;
529        use std::sync::atomic::{AtomicUsize, Ordering};
530
531        let limiter = Arc::new(ConnectionLimiter::new(100, 10));
532        let success_count = Arc::new(AtomicUsize::new(0));
533
534        let mut handles = Vec::new();
535        for _ in 0..20 {
536            let l = limiter.clone();
537            let s = success_count.clone();
538            handles.push(tokio::spawn(async move {
539                // `try_acquire` is fully synchronous — no `.await` while holding
540                // the DashMap shard guard, so there is no risk of deadlock.
541                if l.try_acquire("example.com") {
542                    s.fetch_add(1, Ordering::Relaxed);
543                }
544            }));
545        }
546
547        for h in handles {
548            h.await.unwrap();
549        }
550
551        // per_host_limit is 10, so at most 10 should succeed; global limit (100)
552        // is not the binding constraint here.
553        assert_eq!(
554            success_count.load(Ordering::Relaxed),
555            10,
556            "per-host limit must cap successful acquires"
557        );
558        assert_eq!(
559            limiter.host_count("example.com"),
560            10,
561            "host_count must reflect the 10 successful acquires"
562        );
563        assert_eq!(
564            limiter.global_count(),
565            10,
566            "global_count must equal the 10 successful acquires"
567        );
568
569        // Release some and verify the counts drop accordingly.
570        for _ in 0..5 {
571            limiter.release("example.com");
572        }
573        assert_eq!(
574            limiter.host_count("example.com"),
575            5,
576            "host_count must drop to 5 after 5 releases"
577        );
578        assert_eq!(
579            limiter.global_count(),
580            5,
581            "global_count must drop to 5 after 5 releases"
582        );
583    }
584
585    #[test]
586    fn test_source_scoring_slow_penalized() {
587        // Fast source (1 MB/s)
588        let fast_score = score_source(1_048_576.0, 0, 0);
589
590        // Slow source (1 KB/s)
591        let slow_score = score_source(1024.0, 0, 0);
592
593        // Dead source (no speed + failures)
594        let dead_score = score_source(0.0, 3, 0);
595
596        // Slow source should have higher (worse) score than fast source
597        assert!(
598            slow_score > fast_score,
599            "Slow source should have worse score than fast source"
600        );
601
602        // Dead source should have maximum score
603        assert_eq!(dead_score, f64::MAX, "Dead source should have MAX score");
604
605        // Source with failures should be penalized
606        let failed_score = score_source(1_048_576.0, 2, 0);
607        assert!(
608            failed_score > fast_score,
609            "Failed source should have worse score than successful one"
610        );
611
612        // Recent success should improve score (lower is better)
613        // Note: age_bonus is subtracted, so more recent (smaller age) = smaller subtraction = slightly higher score
614        // But the effect is minimal compared to speed differences
615        let recent_score = score_source(1_048_576.0, 0, 10); // 10 seconds ago
616        let old_score = score_source(1_048_576.0, 0, 300); // 5 minutes ago
617        // Both should have similar base scores (same speed), but old success has larger age bonus subtracted
618        assert!(
619            old_score < recent_score,
620            "Old success should give better (lower) score due to larger age bonus"
621        );
622
623        // Verify that both are still much better than slow sources
624        let very_slow = score_source(1024.0, 0, 0);
625        assert!(
626            recent_score < very_slow,
627            "Even recent fast source beats slow source"
628        );
629    }
630}