Skip to main content

koan_core/remote/
download.rs

1//! Streaming file downloads: temp file, progress reporting, atomic rename, retries.
2//!
3//! Every remote byte koan writes to disk goes through here. `dest` only ever
4//! appears once the transfer completed, so a partially-written file can never
5//! be mistaken for a cached track.
6
7use std::io::{Read, Write};
8use std::path::Path;
9use std::time::{Duration, Instant};
10
11use parking_lot::{Condvar, Mutex};
12use thiserror::Error;
13
14/// Longest the TCP connect + TLS handshake may take.
15const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
16
17/// Longest a single body read may block before the transfer counts as stalled.
18///
19/// `reqwest`'s blocking client re-applies its request timeout to each `Read`
20/// of a streamed response, so this bounds *stalls*, not total transfer time —
21/// a large file on a slow link keeps going as long as bytes keep arriving.
22const STALL_TIMEOUT: Duration = Duration::from_secs(30);
23
24/// Total deadline for JSON API calls, whose bodies are small and read in one go.
25pub const API_TIMEOUT: Duration = Duration::from_secs(30);
26
27/// Names the device class, so a server that tells players apart by user agent
28/// (Navidrome does) sees a Mac and a phone as two players, not one.
29#[cfg(target_os = "macos")]
30const USER_AGENT: &str = concat!("koan/", env!("CARGO_PKG_VERSION"), " (Macintosh)");
31#[cfg(target_os = "ios")]
32const USER_AGENT: &str = concat!("koan/", env!("CARGO_PKG_VERSION"), " (iOS)");
33#[cfg(not(any(target_os = "macos", target_os = "ios")))]
34const USER_AGENT: &str = concat!("koan/", env!("CARGO_PKG_VERSION"), " (Linux)");
35
36/// Attempts a download gets before giving up.
37pub const DEFAULT_ATTEMPTS: u32 = 3;
38
39/// Base backoff between attempts; doubles each retry.
40const BACKOFF_BASE: Duration = Duration::from_millis(500);
41
42/// Waits between tries at a server that is not answering, by how many times in
43/// a row it has not. The last repeats for as long as the outage lasts.
44const OUTAGE_BACKOFF: [Duration; 3] = [
45    Duration::from_secs(5),
46    Duration::from_secs(15),
47    Duration::from_secs(60),
48];
49
50/// Longest `Retry-After` koan honours. A server asking for more is asked again
51/// at this interval instead, so a phone does not sit silent for an hour on the
52/// word of a misconfigured proxy.
53const RETRY_AFTER_CAP: Duration = Duration::from_secs(600);
54
55/// How often a download waiting out an outage checks whether it is still wanted.
56const CANCEL_POLL: Duration = Duration::from_secs(1);
57
58#[derive(Debug, Error)]
59pub enum DownloadError {
60    #[error("http error: {0}")]
61    Http(reqwest::Error),
62    #[error("io error: {0}")]
63    Io(#[from] std::io::Error),
64    #[error("incomplete download: got {got} of {expected} bytes")]
65    Incomplete { got: u64, expected: u64 },
66    #[error("server returned {status}")]
67    Status {
68        status: reqwest::StatusCode,
69        /// The server's `Retry-After`, when it sent one in seconds.
70        retry_after: Option<Duration>,
71    },
72    #[error("request could not be built: {0}")]
73    Request(String),
74    #[error("no longer wanted")]
75    Cancelled,
76}
77
78/// Without the URL, which carries the account's credentials; see
79/// `SubsonicError`'s conversion.
80impl From<reqwest::Error> for DownloadError {
81    fn from(e: reqwest::Error) -> Self {
82        Self::Http(e.without_url())
83    }
84}
85
86impl DownloadError {
87    /// Whether another attempt could plausibly succeed: transport-level
88    /// failures, truncated bodies, and server-side/rate-limit statuses.
89    pub fn is_retryable(&self) -> bool {
90        match self {
91            DownloadError::Http(e) => e.is_timeout() || e.is_connect() || e.is_request(),
92            DownloadError::Io(_) | DownloadError::Incomplete { .. } => true,
93            DownloadError::Status { status, .. } => {
94                status.is_server_error() || *status == reqwest::StatusCode::TOO_MANY_REQUESTS
95            }
96            DownloadError::Request(_) | DownloadError::Cancelled => false,
97        }
98    }
99
100    /// Whether this says the server is not answering at all, rather than that
101    /// this track cannot be had. Every other download would get the same
102    /// answer, so it is waited out instead of counted against the track.
103    ///
104    /// A 500, a 404, a body cut short or an error document are about the track.
105    pub fn is_unavailable(&self) -> bool {
106        use reqwest::StatusCode;
107        match self {
108            // Refused, no route, no DNS, and a connect that timed out.
109            DownloadError::Http(e) => e.is_connect(),
110            DownloadError::Status { status, .. } => matches!(
111                *status,
112                StatusCode::SERVICE_UNAVAILABLE
113                    | StatusCode::TOO_MANY_REQUESTS
114                    | StatusCode::BAD_GATEWAY
115                    | StatusCode::GATEWAY_TIMEOUT
116            ),
117            _ => false,
118        }
119    }
120
121    fn retry_after(&self) -> Option<Duration> {
122        match self {
123            DownloadError::Status { retry_after, .. } => *retry_after,
124            _ => None,
125        }
126    }
127}
128
129/// How long to wait before trying a server that has not answered `failures`
130/// times in a row (counting from 1). The server's own `Retry-After` wins.
131pub fn outage_backoff(failures: u32, retry_after: Option<Duration>) -> Duration {
132    if let Some(wait) = retry_after {
133        return wait.min(RETRY_AFTER_CAP);
134    }
135    let step = (failures.max(1) - 1) as usize;
136    OUTAGE_BACKOFF[step.min(OUTAGE_BACKOFF.len() - 1)]
137}
138
139/// `Retry-After` in delta-seconds. The HTTP-date form is left to the schedule.
140fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
141    headers
142        .get(reqwest::header::RETRY_AFTER)?
143        .to_str()
144        .ok()?
145        .trim()
146        .parse()
147        .ok()
148        .map(Duration::from_secs)
149}
150
151/// Whether a server is answering, shared by every download against it.
152///
153/// One transfer finding it down makes the rest wait with it, and while it is
154/// down only one of them at a time asks again. Without this each download
155/// would learn of the outage for itself and give up, and the player would
156/// move on to the next track to do the same.
157#[derive(Default)]
158pub struct Outage {
159    state: Mutex<OutageState>,
160    changed: Condvar,
161}
162
163#[derive(Default)]
164struct OutageState {
165    /// Unanswered tries in a row. Zero while the server is up.
166    failures: u32,
167    /// When the next try may go. `None` while the server is up.
168    retry_at: Option<Instant>,
169}
170
171impl Outage {
172    pub fn is_down(&self) -> bool {
173        self.state.lock().retry_at.is_some()
174    }
175
176    /// Make the next try due now, keeping the count of failures: if this one
177    /// fails too, the backoff carries on from where it was.
178    pub fn retry_now(&self) {
179        let mut s = self.state.lock();
180        if s.retry_at.is_none() {
181            return;
182        }
183        s.retry_at = Some(Instant::now());
184        drop(s);
185        self.changed.notify_all();
186    }
187
188    /// Block while the server is down and nobody is due to try it. Returns
189    /// immediately when it is up, or once a try is due.
190    pub fn hold(&self) {
191        let mut s = self.state.lock();
192        while let Some(at) = s.retry_at {
193            let now = Instant::now();
194            if now >= at {
195                break;
196            }
197            self.changed.wait_for(&mut s, at - now);
198        }
199    }
200
201    /// Wait until this download may try the server: at once when it is up,
202    /// otherwise when the next try is due and no other download has taken it.
203    /// Returns `false` if `cancelled` says the download stopped being wanted.
204    fn admit(&self, cancelled: &dyn Fn() -> bool) -> bool {
205        let mut s = self.state.lock();
206        loop {
207            // Asked outside the lock: it reads the player's state.
208            if parking_lot::MutexGuard::unlocked(&mut s, cancelled) {
209                return false;
210            }
211            let Some(at) = s.retry_at else {
212                return true;
213            };
214            let now = Instant::now();
215            if now >= at {
216                // This one tries; the rest wait for its answer. Pushed out
217                // rather than flagged, so a try that never reports back
218                // (it panicked, say) holds the others up for one step only.
219                s.retry_at = Some(now + outage_backoff(s.failures, None));
220                return true;
221            }
222            self.changed.wait_for(&mut s, (at - now).min(CANCEL_POLL));
223        }
224    }
225
226    /// The server did not answer. Returns how long until it is tried again.
227    fn down(&self, retry_after: Option<Duration>) -> Duration {
228        let mut s = self.state.lock();
229        s.failures += 1;
230        let wait = outage_backoff(s.failures, retry_after);
231        s.retry_at = Some(Instant::now() + wait);
232        drop(s);
233        // Those waiting for this answer take the new time from here.
234        self.changed.notify_all();
235        wait
236    }
237
238    /// The server answered, whatever it said.
239    fn up(&self) {
240        let mut s = self.state.lock();
241        if s.retry_at.is_none() {
242            return;
243        }
244        log::info!(
245            "remote server answering again after {} failed tries",
246            s.failures
247        );
248        *s = OutageState::default();
249        drop(s);
250        self.changed.notify_all();
251    }
252}
253
254/// Wait out a server that is not answering rather than fail against it.
255pub struct Patience<'a> {
256    pub outage: &'a Outage,
257    /// Asked while waiting out the server and as each chunk lands; `true`
258    /// ends the download with `DownloadError::Cancelled`.
259    pub cancelled: &'a dyn Fn() -> bool,
260}
261
262/// HTTP client for streaming large bodies — bounded connect, bounded stalls,
263/// no total deadline on the transfer.
264pub fn download_client() -> reqwest::Result<reqwest::blocking::Client> {
265    reqwest::blocking::Client::builder()
266        .connect_timeout(CONNECT_TIMEOUT)
267        .timeout(STALL_TIMEOUT)
268        .user_agent(USER_AGENT)
269        .build()
270}
271
272/// HTTP client for small JSON API calls, where a total request deadline is correct.
273pub fn api_client() -> reqwest::Result<reqwest::blocking::Client> {
274    reqwest::blocking::Client::builder()
275        .connect_timeout(CONNECT_TIMEOUT)
276        .timeout(API_TIMEOUT)
277        .user_agent(USER_AGENT)
278        .build()
279}
280
281/// Download to `dest`, retrying transient failures with exponential backoff.
282///
283/// `request` is invoked once per attempt so per-request state (Subsonic auth
284/// salts, for one) is regenerated rather than replayed; a request that cannot
285/// be built is fatal, not retried. `on_progress` receives
286/// `(bytes_this_attempt, total)` where `total` is 0 if the server sent no
287/// Content-Length; it restarts from zero when an attempt is retried.
288///
289/// With `patience`, a server that is not answering (`is_unavailable`) is waited
290/// out on its `Outage` for as long as it takes, and those tries do not count
291/// against `attempts`. Without it they are retried like any transient failure.
292///
293/// Returns the number of bytes written. `dest` is left untouched on failure.
294pub fn download_with_retries(
295    dest: &Path,
296    attempts: u32,
297    patience: Option<Patience<'_>>,
298    request: impl Fn() -> Result<reqwest::blocking::RequestBuilder, DownloadError>,
299    on_progress: impl Fn(u64, u64),
300) -> Result<u64, DownloadError> {
301    let attempts = attempts.max(1);
302    let mut failures = 0;
303    let cancelled: &dyn Fn() -> bool = match &patience {
304        Some(p) => p.cancelled,
305        None => &|| false,
306    };
307
308    let err = loop {
309        if let Some(p) = &patience
310            && !p.outage.admit(p.cancelled)
311        {
312            break DownloadError::Cancelled;
313        }
314
315        let e = match attempt_download(dest, &request, &on_progress, cancelled) {
316            Ok(bytes) => {
317                if let Some(p) = &patience {
318                    p.outage.up();
319                }
320                return Ok(bytes);
321            }
322            Err(e) => e,
323        };
324
325        if let Some(p) = &patience {
326            if e.is_unavailable() {
327                let wait = p.outage.down(e.retry_after());
328                log::warn!(
329                    "download of {}: server unavailable ({}), trying again in {:?}",
330                    dest.display(),
331                    e,
332                    wait
333                );
334                continue;
335            }
336            p.outage.up();
337        }
338
339        failures += 1;
340        if !e.is_retryable() || failures >= attempts {
341            break e;
342        }
343        let backoff = BACKOFF_BASE * 2u32.pow(failures - 1);
344        log::warn!(
345            "download of {} failed ({}), retrying in {:?} ({}/{})",
346            dest.display(),
347            e,
348            backoff,
349            failures + 1,
350            attempts
351        );
352        std::thread::sleep(backoff);
353    };
354
355    // Only once the download has given up. Between attempts the `.part` stays,
356    // and the next one truncates it in place: a stream already reading it holds
357    // that inode, and picks up again as the retry rewrites the same bytes.
358    let _ = std::fs::remove_file(part_path(dest));
359    Err(err)
360}
361
362fn attempt_download(
363    dest: &Path,
364    request: &impl Fn() -> Result<reqwest::blocking::RequestBuilder, DownloadError>,
365    on_progress: &impl Fn(u64, u64),
366    cancelled: &dyn Fn() -> bool,
367) -> Result<u64, DownloadError> {
368    let resp = request()?.send()?;
369    let status = resp.status();
370    // Status first: a 503 with a JSON body is transient and worth retrying.
371    if !status.is_success() {
372        return Err(DownloadError::Status {
373            status,
374            retry_after: parse_retry_after(resp.headers()),
375        });
376    }
377    // Subsonic reports failure with HTTP 200 and a JSON or XML error body, so a
378    // success status proves nothing on a binary endpoint. Without this, an error
379    // response gets written to disk and cached as if it were audio — it then
380    // reports Ready and fails to decode forever.
381    if resp
382        .headers()
383        .get(reqwest::header::CONTENT_TYPE)
384        .and_then(|v| v.to_str().ok())
385        .is_some_and(|ct| ct.contains("json") || ct.contains("xml"))
386    {
387        return Err(DownloadError::Request(
388            "server returned an error document where audio was expected".into(),
389        ));
390    }
391    stream_to_file(resp, dest, on_progress, cancelled)
392}
393
394/// Stream a response body into `dest` via a `.part` sibling, renaming only once
395/// the transfer completes. A read error or a body shorter than the advertised
396/// Content-Length errors, leaving the temp file for the caller to retry into.
397fn stream_to_file(
398    mut resp: reqwest::blocking::Response,
399    dest: &Path,
400    on_progress: &impl Fn(u64, u64),
401    cancelled: &dyn Fn() -> bool,
402) -> Result<u64, DownloadError> {
403    let total = resp.content_length().unwrap_or(0);
404
405    if let Some(parent) = dest.parent() {
406        std::fs::create_dir_all(parent)?;
407    }
408
409    let tmp = part_path(dest);
410    let mut file = std::fs::File::create(&tmp)?;
411    let mut downloaded: u64 = 0;
412    let mut buf = [0u8; 64 * 1024];
413
414    let result = loop {
415        match resp.read(&mut buf) {
416            Ok(0) => break Ok(()),
417            Ok(n) => {
418                if let Err(e) = file.write_all(&buf[..n]) {
419                    break Err(DownloadError::Io(e));
420                }
421                downloaded += n as u64;
422                on_progress(downloaded, total);
423                if cancelled() {
424                    break Err(DownloadError::Cancelled);
425                }
426            }
427            Err(e) => break Err(DownloadError::Io(e)),
428        }
429    };
430
431    let flushed = file.flush();
432    drop(file);
433
434    let outcome = result
435        .and_then(|()| flushed.map_err(DownloadError::Io))
436        .and_then(|()| {
437            if total > 0 && downloaded != total {
438                Err(DownloadError::Incomplete {
439                    got: downloaded,
440                    expected: total,
441                })
442            } else {
443                Ok(())
444            }
445        });
446
447    outcome?;
448    std::fs::rename(&tmp, dest)?;
449    Ok(downloaded)
450}
451
452/// The in-progress sibling of `dest`. Appends `.part` rather than replacing the
453/// extension, so `Song.flac` and `Song.mp3` never collide on one temp file.
454pub fn part_path(dest: &Path) -> std::path::PathBuf {
455    let mut name = dest.file_name().unwrap_or_default().to_os_string();
456    name.push(".part");
457    dest.with_file_name(name)
458}
459
460/// Strip a `.part` suffix, yielding the final path a download will land at.
461/// Returns `path` unchanged when it isn't a temp file.
462pub fn strip_part_suffix(path: &Path) -> std::path::PathBuf {
463    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
464        return path.to_path_buf();
465    };
466    match name.strip_suffix(".part") {
467        Some(stripped) => path.with_file_name(stripped),
468        None => path.to_path_buf(),
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use std::io::BufRead;
476    use std::net::{TcpListener, TcpStream};
477    use std::sync::Arc;
478    use std::sync::atomic::{AtomicUsize, Ordering};
479
480    /// How a stub server answers one request.
481    #[derive(Clone)]
482    enum Reply {
483        /// Content-Length header, then that many bytes.
484        Complete(Vec<u8>),
485        /// Content-Length claims `claimed` bytes but only `body` is sent, then close.
486        Truncated {
487            claimed: usize,
488            body: Vec<u8>,
489        },
490        /// Chunked with no Content-Length, cut off mid-stream — what Navidrome
491        /// does for transcoded streams when the connection drops.
492        ChunkedTruncated(Vec<u8>),
493        ServerError,
494        /// 503, with a `Retry-After` in seconds when given.
495        Unavailable(Option<u64>),
496    }
497
498    /// Single-threaded stub HTTP server. Serves `replies` in order, repeating
499    /// the last one forever. Shuts down when the returned handle is dropped.
500    struct StubServer {
501        addr: std::net::SocketAddr,
502        hits: Arc<AtomicUsize>,
503        shutdown: Arc<std::sync::atomic::AtomicBool>,
504    }
505
506    impl StubServer {
507        fn start(replies: Vec<Reply>) -> Self {
508            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
509            listener.set_nonblocking(true).unwrap();
510            let addr = listener.local_addr().unwrap();
511            let hits = Arc::new(AtomicUsize::new(0));
512            let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
513
514            let hits_bg = hits.clone();
515            let shutdown_bg = shutdown.clone();
516            std::thread::spawn(move || {
517                while !shutdown_bg.load(Ordering::Relaxed) {
518                    match listener.accept() {
519                        Ok((stream, _)) => {
520                            // BSD sockets inherit O_NONBLOCK from the listener.
521                            let _ = stream.set_nonblocking(false);
522                            let n = hits_bg.fetch_add(1, Ordering::SeqCst);
523                            let reply = replies[n.min(replies.len() - 1)].clone();
524                            serve_one(stream, reply);
525                        }
526                        Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
527                            std::thread::sleep(Duration::from_millis(5));
528                        }
529                        Err(_) => break,
530                    }
531                }
532            });
533
534            Self {
535                addr,
536                hits,
537                shutdown,
538            }
539        }
540
541        fn url(&self) -> String {
542            format!("http://{}/file", self.addr)
543        }
544
545        fn hits(&self) -> usize {
546            self.hits.load(Ordering::SeqCst)
547        }
548    }
549
550    impl Drop for StubServer {
551        fn drop(&mut self) {
552            self.shutdown.store(true, Ordering::Relaxed);
553        }
554    }
555
556    fn serve_one(mut stream: TcpStream, reply: Reply) {
557        // Drain the request headers so the client isn't left writing into a
558        // closed socket before it can read the response.
559        let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
560        let mut line = String::new();
561        while reader.read_line(&mut line).unwrap_or(0) > 0 {
562            if line == "\r\n" || line == "\n" {
563                break;
564            }
565            line.clear();
566        }
567
568        match reply {
569            Reply::Complete(body) => {
570                let _ = write!(
571                    stream,
572                    "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: {}\r\n\r\n",
573                    body.len()
574                );
575                let _ = stream.write_all(&body);
576            }
577            Reply::Truncated { claimed, body } => {
578                let _ = write!(
579                    stream,
580                    "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: {}\r\n\r\n",
581                    claimed
582                );
583                let _ = stream.write_all(&body);
584            }
585            Reply::ChunkedTruncated(body) => {
586                let _ = write!(
587                    stream,
588                    "HTTP/1.1 200 OK\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n"
589                );
590                let _ = write!(stream, "{:x}\r\n", body.len());
591                let _ = stream.write_all(&body);
592                let _ = stream.write_all(b"\r\n");
593                // No terminating zero-length chunk — the stream just stops.
594            }
595            Reply::ServerError => {
596                let _ = write!(
597                    stream,
598                    "HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n"
599                );
600            }
601            Reply::Unavailable(retry_after) => {
602                let header = retry_after
603                    .map(|s| format!("Retry-After: {s}\r\n"))
604                    .unwrap_or_default();
605                let _ = write!(
606                    stream,
607                    "HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n{header}Content-Length: 0\r\n\r\n"
608                );
609            }
610        }
611        // Every reply says `Connection: close` because of this. Left to assume
612        // keep-alive, the client can send its next request down this socket
613        // before the close reaches it, and that fails as a broken connection.
614        let _ = stream.flush();
615        let _ = stream.shutdown(std::net::Shutdown::Both);
616    }
617
618    fn tmp_dest(dir: &tempfile::TempDir) -> std::path::PathBuf {
619        dir.path().join("nested").join("track.flac")
620    }
621
622    #[test]
623    fn complete_download_lands_at_dest() {
624        let body = vec![7u8; 200_000];
625        let server = StubServer::start(vec![Reply::Complete(body.clone())]);
626        let dir = tempfile::tempdir().unwrap();
627        let dest = tmp_dest(&dir);
628        let client = download_client().unwrap();
629
630        let written =
631            download_with_retries(&dest, 1, None, || Ok(client.get(server.url())), |_, _| {})
632                .unwrap();
633
634        assert_eq!(written, body.len() as u64);
635        assert_eq!(std::fs::read(&dest).unwrap(), body);
636        assert!(!part_path(&dest).exists(), "temp file should be cleaned up");
637    }
638
639    #[test]
640    fn truncated_body_errors_and_leaves_no_file() {
641        let server = StubServer::start(vec![Reply::Truncated {
642            claimed: 100_000,
643            body: vec![1u8; 4_096],
644        }]);
645        let dir = tempfile::tempdir().unwrap();
646        let dest = tmp_dest(&dir);
647        let client = download_client().unwrap();
648
649        let err = download_with_retries(&dest, 1, None, || Ok(client.get(server.url())), |_, _| {})
650            .expect_err("a short body must not succeed");
651
652        assert!(
653            matches!(err, DownloadError::Incomplete { .. } | DownloadError::Io(_)),
654            "unexpected error: {err}"
655        );
656        assert!(!dest.exists(), "dest must not hold a truncated file");
657        assert!(!part_path(&dest).exists(), "temp file must be removed");
658    }
659
660    #[test]
661    fn missing_content_length_truncation_errors_rather_than_completing() {
662        // No Content-Length at all — the only signal is the stream ending
663        // mid-message, which must not be read as a finished download.
664        let server = StubServer::start(vec![Reply::ChunkedTruncated(vec![9u8; 8_192])]);
665        let dir = tempfile::tempdir().unwrap();
666        let dest = tmp_dest(&dir);
667        let client = download_client().unwrap();
668
669        let err = download_with_retries(&dest, 1, None, || Ok(client.get(server.url())), |_, _| {})
670            .expect_err("a cut-off chunked body must not succeed");
671
672        assert!(matches!(err, DownloadError::Io(_)), "unexpected: {err}");
673        assert!(!dest.exists(), "dest must not hold a truncated file");
674        assert!(!part_path(&dest).exists(), "temp file must be removed");
675    }
676
677    #[test]
678    fn retries_transient_failure_then_succeeds() {
679        let body = vec![3u8; 50_000];
680        let server = StubServer::start(vec![
681            Reply::ServerError,
682            Reply::Truncated {
683                claimed: 50_000,
684                body: vec![3u8; 10],
685            },
686            Reply::Complete(body.clone()),
687        ]);
688        let dir = tempfile::tempdir().unwrap();
689        let dest = tmp_dest(&dir);
690        let client = download_client().unwrap();
691
692        let written =
693            download_with_retries(&dest, 3, None, || Ok(client.get(server.url())), |_, _| {})
694                .unwrap();
695
696        assert_eq!(written, body.len() as u64);
697        assert_eq!(server.hits(), 3, "should have used all three attempts");
698        assert_eq!(std::fs::read(&dest).unwrap(), body);
699    }
700
701    /// A stream reading the `.part` holds its inode, so a retry has to write
702    /// into that same file rather than a new one beside it.
703    #[cfg(unix)]
704    #[test]
705    fn retry_rewrites_the_same_part_file() {
706        use std::os::unix::fs::MetadataExt;
707
708        let server = StubServer::start(vec![
709            Reply::Truncated {
710                claimed: 50_000,
711                body: vec![3u8; 10],
712            },
713            Reply::Complete(vec![3u8; 50_000]),
714        ]);
715        let dir = tempfile::tempdir().unwrap();
716        let dest = tmp_dest(&dir);
717        let client = download_client().unwrap();
718
719        let inodes = std::sync::Mutex::new(std::collections::HashSet::new());
720        download_with_retries(
721            &dest,
722            2,
723            None,
724            || Ok(client.get(server.url())),
725            |_, _| {
726                if let Ok(meta) = std::fs::metadata(part_path(&dest)) {
727                    inodes.lock().unwrap().insert(meta.ino());
728                }
729            },
730        )
731        .unwrap();
732
733        assert_eq!(inodes.lock().unwrap().len(), 1);
734    }
735
736    #[test]
737    fn progress_reports_total_when_content_length_present() {
738        let body = vec![0u8; 300_000];
739        let server = StubServer::start(vec![Reply::Complete(body.clone())]);
740        let dir = tempfile::tempdir().unwrap();
741        let dest = tmp_dest(&dir);
742        let client = download_client().unwrap();
743
744        let seen = std::sync::Mutex::new(Vec::new());
745        download_with_retries(
746            &dest,
747            1,
748            None,
749            || Ok(client.get(server.url())),
750            |d, t| {
751                seen.lock().unwrap().push((d, t));
752            },
753        )
754        .unwrap();
755
756        let seen = seen.into_inner().unwrap();
757        assert!(!seen.is_empty(), "progress should be reported");
758        assert!(seen.iter().all(|(_, t)| *t == body.len() as u64));
759        assert_eq!(seen.last().unwrap().0, body.len() as u64);
760    }
761
762    #[test]
763    fn part_path_appends_rather_than_replacing_extension() {
764        let flac = part_path(Path::new("/tmp/Song.flac"));
765        let mp3 = part_path(Path::new("/tmp/Song.mp3"));
766        assert_eq!(flac, Path::new("/tmp/Song.flac.part"));
767        assert_ne!(flac, mp3, "different codecs must not share a temp file");
768    }
769
770    #[test]
771    fn strip_part_suffix_round_trips() {
772        let dest = Path::new("/tmp/a/Song.flac");
773        assert_eq!(strip_part_suffix(&part_path(dest)), dest);
774        assert_eq!(strip_part_suffix(dest), dest);
775    }
776
777    fn status(code: u16) -> DownloadError {
778        DownloadError::Status {
779            status: reqwest::StatusCode::from_u16(code).unwrap(),
780            retry_after: None,
781        }
782    }
783
784    #[test]
785    fn an_unanswering_server_is_told_apart_from_a_bad_track() {
786        for code in [503, 429, 502, 504] {
787            assert!(status(code).is_unavailable(), "{code} is the server");
788        }
789        for code in [404, 500, 403] {
790            assert!(!status(code).is_unavailable(), "{code} is the track");
791        }
792        assert!(
793            !DownloadError::Incomplete {
794                got: 1,
795                expected: 2
796            }
797            .is_unavailable()
798        );
799        assert!(!DownloadError::Request("error document".into()).is_unavailable());
800    }
801
802    #[test]
803    fn a_refused_connection_is_the_server_being_unavailable() {
804        // A port nothing listens on.
805        let addr = TcpListener::bind("127.0.0.1:0")
806            .unwrap()
807            .local_addr()
808            .unwrap();
809        let err = download_client()
810            .unwrap()
811            .get(format!("http://{addr}/file"))
812            .send()
813            .map(|_| ())
814            .map_err(DownloadError::from)
815            .expect_err("nothing is listening");
816        assert!(err.is_unavailable(), "unexpected: {err}");
817    }
818
819    #[test]
820    fn asking_for_music_cuts_an_outage_wait_short() {
821        let outage = Outage::default();
822        outage.down(Some(Duration::from_secs(600)));
823        outage.retry_now();
824        let started = Instant::now();
825        outage.hold();
826        assert!(
827            started.elapsed() < Duration::from_secs(1),
828            "a try is due at once"
829        );
830        assert!(outage.is_down(), "still down until a try succeeds");
831
832        // Failing again carries the backoff on from the count it had.
833        let wait = outage.down(None);
834        assert_eq!(wait, outage_backoff(2, None));
835
836        // Nothing to cut short while the server is up.
837        let up = Outage::default();
838        up.retry_now();
839        assert!(!up.is_down());
840    }
841
842    #[test]
843    fn outage_backoff_steps_then_holds_and_defers_to_retry_after() {
844        let s = Duration::from_secs;
845        let schedule: Vec<_> = (1..=5).map(|n| outage_backoff(n, None)).collect();
846        assert_eq!(schedule, [s(5), s(15), s(60), s(60), s(60)]);
847        assert_eq!(outage_backoff(1, Some(s(2))), s(2));
848        assert_eq!(outage_backoff(4, Some(s(0))), s(0));
849        assert_eq!(outage_backoff(1, Some(s(86_400))), RETRY_AFTER_CAP);
850    }
851
852    fn patient<'a>(outage: &'a Outage, cancelled: &'a dyn Fn() -> bool) -> Option<Patience<'a>> {
853        Some(Patience { outage, cancelled })
854    }
855
856    #[test]
857    fn retry_after_is_read_and_honoured() {
858        let server = StubServer::start(vec![
859            Reply::Unavailable(Some(1)),
860            Reply::Complete(vec![1u8; 1_000]),
861        ]);
862        let dir = tempfile::tempdir().unwrap();
863        let dest = tmp_dest(&dir);
864        let client = download_client().unwrap();
865        let outage = Outage::default();
866
867        let started = Instant::now();
868        download_with_retries(
869            &dest,
870            1,
871            patient(&outage, &|| false),
872            || Ok(client.get(server.url())),
873            |_, _| {},
874        )
875        .unwrap();
876
877        assert!(
878            started.elapsed() >= Duration::from_secs(1),
879            "waited as asked"
880        );
881        assert!(
882            started.elapsed() < OUTAGE_BACKOFF[0],
883            "not the default wait"
884        );
885        assert!(!outage.is_down(), "a success says the server is back");
886    }
887
888    #[test]
889    fn an_outage_does_not_spend_the_tracks_attempts() {
890        // Nine unanswered tries against one attempt: none of them is the
891        // track's fault, so none of them counts.
892        let mut replies = vec![Reply::Unavailable(Some(0)); 9];
893        replies.push(Reply::Complete(vec![2u8; 1_000]));
894        let server = StubServer::start(replies);
895        let dir = tempfile::tempdir().unwrap();
896        let dest = tmp_dest(&dir);
897        let client = download_client().unwrap();
898
899        download_with_retries(
900            &dest,
901            1,
902            patient(&Outage::default(), &|| false),
903            || Ok(client.get(server.url())),
904            |_, _| {},
905        )
906        .unwrap();
907        assert_eq!(server.hits(), 10);
908    }
909
910    #[test]
911    fn without_patience_an_outage_fails_as_before() {
912        let server = StubServer::start(vec![Reply::Unavailable(Some(0))]);
913        let dir = tempfile::tempdir().unwrap();
914        let dest = tmp_dest(&dir);
915        let client = download_client().unwrap();
916
917        let err = download_with_retries(&dest, 2, None, || Ok(client.get(server.url())), |_, _| {})
918            .expect_err("bounded attempts");
919        assert!(err.is_unavailable());
920        assert_eq!(server.hits(), 2);
921    }
922
923    #[test]
924    fn a_download_waiting_out_an_outage_stops_when_no_longer_wanted() {
925        let server = StubServer::start(vec![Reply::Unavailable(None)]);
926        let dir = tempfile::tempdir().unwrap();
927        let dest = tmp_dest(&dir);
928        let client = download_client().unwrap();
929        let outage = Outage::default();
930        let wanted = std::sync::atomic::AtomicBool::new(true);
931
932        let started = Instant::now();
933        let err = std::thread::scope(|scope| {
934            scope.spawn(|| {
935                std::thread::sleep(Duration::from_millis(200));
936                wanted.store(false, Ordering::Relaxed);
937            });
938            download_with_retries(
939                &dest,
940                3,
941                patient(&outage, &|| !wanted.load(Ordering::Relaxed)),
942                || Ok(client.get(server.url())),
943                |_, _| {},
944            )
945            .expect_err("cancelled")
946        });
947
948        assert!(matches!(err, DownloadError::Cancelled), "unexpected: {err}");
949        assert!(
950            started.elapsed() < OUTAGE_BACKOFF[0],
951            "stopped within a poll, not at the next try"
952        );
953        assert_eq!(server.hits(), 1);
954        assert!(!part_path(&dest).exists());
955    }
956
957    #[test]
958    fn a_download_no_longer_wanted_stops_mid_transfer() {
959        // Not only while waiting out an outage: a cleared queue should not go
960        // on fetching an album nobody is going to play.
961        let server = StubServer::start(vec![Reply::Complete(vec![7u8; 4 * 1024 * 1024])]);
962        let dir = tempfile::tempdir().unwrap();
963        let dest = tmp_dest(&dir);
964        let client = download_client().unwrap();
965        let outage = Outage::default();
966        let received = AtomicUsize::new(0);
967
968        let err = download_with_retries(
969            &dest,
970            3,
971            patient(&outage, &|| received.load(Ordering::Relaxed) > 0),
972            || Ok(client.get(server.url())),
973            |bytes, _| received.store(bytes as usize, Ordering::Relaxed),
974        )
975        .expect_err("cancelled");
976
977        assert!(matches!(err, DownloadError::Cancelled), "unexpected: {err}");
978        assert!(received.load(Ordering::Relaxed) < 4 * 1024 * 1024);
979        assert_eq!(server.hits(), 1, "not retried");
980        assert!(!dest.exists());
981        assert!(!part_path(&dest).exists());
982    }
983
984    #[test]
985    fn hold_blocks_while_down_and_lets_go_when_the_server_answers() {
986        let outage = Outage::default();
987        outage.down(Some(Duration::from_secs(60)));
988        assert!(outage.is_down());
989
990        let started = Instant::now();
991        std::thread::scope(|scope| {
992            scope.spawn(|| {
993                std::thread::sleep(Duration::from_millis(100));
994                outage.up();
995            });
996            outage.hold();
997        });
998        let waited = started.elapsed();
999        assert!(waited >= Duration::from_millis(100), "held while down");
1000        assert!(waited < Duration::from_secs(5), "released by the answer");
1001    }
1002
1003    /// The queue's discipline — each worker holds while the server is down,
1004    /// then downloads — against a server that answers nothing for a while.
1005    #[test]
1006    fn a_queue_against_an_unavailable_server_fails_nothing_and_resumes() {
1007        const TRACKS: usize = 6;
1008        const UNANSWERED: usize = 12;
1009        let mut replies = vec![Reply::Unavailable(Some(0)); UNANSWERED];
1010        replies.push(Reply::Complete(vec![5u8; 10_000]));
1011        let server = StubServer::start(replies);
1012        let dir = tempfile::tempdir().unwrap();
1013        let client = download_client().unwrap();
1014        let outage = Outage::default();
1015
1016        let results: Vec<_> = std::thread::scope(|scope| {
1017            let handles: Vec<_> = (0..TRACKS)
1018                .map(|i| {
1019                    let (client, outage, server, dir) = (&client, &outage, &server, &dir);
1020                    scope.spawn(move || {
1021                        outage.hold();
1022                        let dest = dir.path().join(format!("{i}.flac"));
1023                        download_with_retries(
1024                            &dest,
1025                            DEFAULT_ATTEMPTS,
1026                            patient(outage, &|| false),
1027                            || Ok(client.get(server.url())),
1028                            |_, _| {},
1029                        )
1030                        .map(|_| dest)
1031                    })
1032                })
1033                .collect();
1034            handles.into_iter().map(|h| h.join().unwrap()).collect()
1035        });
1036
1037        for result in results {
1038            let dest = result.expect("no track fails for the server being down");
1039            assert_eq!(std::fs::read(dest).unwrap().len(), 10_000);
1040        }
1041        assert_eq!(server.hits(), UNANSWERED + TRACKS);
1042        assert!(!outage.is_down());
1043    }
1044}