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