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