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