Skip to main content

libfw_client/native/
download.rs

1//! Native single-file download: resumable `Range` GETs, optional parallel
2//! range window, zrip decompression and stall-based retries.
3//!
4//! Mirrors the browser engine's `download.rs` behaviour (`HEAD` metadata,
5//! per-chunk `Range` + `If-Range`, in-order writes, restart when the remote
6//! file changed) but writes to a local path with `tokio::fs`.
7
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10
11use futures::StreamExt;
12use libfw_core::compress::{CompressionFormat, decompressor};
13use libfw_core::{HEADER_COMPRESS, MIN_PARALLEL_DOWNLOAD_BYTES};
14use reqwest::header::{CONTENT_LENGTH, ETAG, IF_RANGE, RANGE};
15use reqwest::StatusCode;
16use serde::{Deserialize, Serialize};
17use tokio::io::{AsyncSeekExt, AsyncWriteExt};
18
19use super::{NativeClient, NativeEvent, validate_relative};
20use crate::LibfwError;
21use crate::tune::{TransferKind, now_ms};
22
23/// Persisted resume state for one destination file.
24///
25/// The browser engine keeps this in IndexedDB; natively a tiny JSON sidecar
26/// next to the destination is enough. It is keyed by the server's ETag, so a
27/// changed remote file (new ETag) transparently restarts from byte 0.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29struct ResumeState {
30    etag: String,
31    offset: u64,
32}
33
34/// Sidecar path for `dest` (`<dest>.libfw-resume.json`).
35fn resume_path(dest: &Path) -> PathBuf {
36    let mut name = dest.as_os_str().to_owned();
37    name.push(".libfw-resume.json");
38    PathBuf::from(name)
39}
40
41fn load_resume(dest: &Path) -> Option<ResumeState> {
42    let text = std::fs::read_to_string(resume_path(dest)).ok()?;
43    serde_json::from_str(&text).ok()
44}
45
46fn save_resume(dest: &Path, etag: &str, offset: u64) -> Result<(), LibfwError> {
47    let state = ResumeState {
48        etag: etag.to_string(),
49        offset,
50    };
51    let text = serde_json::to_string(&state)
52        .map_err(|e| LibfwError::Storage(format!("resume state: {e}")))?;
53    if let Some(parent) = dest.parent() {
54        std::fs::create_dir_all(parent)
55            .map_err(|e| LibfwError::Storage(format!("create dir {}: {e}", parent.display())))?;
56    }
57    std::fs::write(resume_path(dest), text)
58        .map_err(|e| LibfwError::Storage(format!("write resume state: {e}")))
59}
60
61/// Save the resume offset at most once per this many bytes (matches the
62/// browser engine's periodic persistence).
63const RESUME_SAVE_EVERY: u64 = 4 * 1024 * 1024;
64
65/// The contiguous `[start, end)` ranges covering `[from, size)`.
66fn parallel_chunks(from: u64, size: u64, chunk_size: u64) -> Vec<(u64, u64)> {
67    let chunk_size = if chunk_size == 0 {
68        libfw_core::CHUNK_SIZE
69    } else {
70        chunk_size
71    };
72    let mut chunks = Vec::new();
73    let mut offset = from.min(size);
74    while offset < size {
75        let end = (offset + chunk_size).min(size);
76        chunks.push((offset, end));
77        offset = end;
78    }
79    chunks
80}
81
82/// Whether the chunked byte-range path should be used for this file.
83///
84/// Window-independent (mirrors the browser engine): any transfer with enough
85/// remaining bytes to amortise the per-request overhead is chunked, *even at
86/// window 1*, because the batch loop re-reads the live parameters between
87/// batches — so a ramp that grows the window/chunk size mid-file takes effect
88/// on the current download instead of only on the next one. Small files and
89/// short tails stay on the streaming path, where one request wins.
90fn should_chunked(size: u64, resume_offset: u64) -> bool {
91    size >= MIN_PARALLEL_DOWNLOAD_BYTES
92        && size.saturating_sub(resume_offset) >= MIN_PARALLEL_DOWNLOAD_BYTES
93}
94
95/// Bytes one batch may fetch: `window` chunks, never past the end.
96fn batch_span(remaining: u64, chunk_size: u64, window: usize) -> u64 {
97    let chunk_size = chunk_size.max(1);
98    let window = window.max(1) as u64;
99    chunk_size
100        .saturating_mul(window)
101        .max(chunk_size)
102        .min(remaining.max(1))
103}
104
105impl NativeClient {
106    /// Download `remote` into `dest`, resuming a previous partial download
107    /// when the server's ETag still matches.
108    ///
109    /// Returns the number of bytes written **by this run** (0 when the file
110    /// was already complete), so callers can tell a resume from a full fetch.
111    /// Emits [`NativeEvent::FileStart`], [`NativeEvent::Progress`] (per file),
112    /// [`NativeEvent::FileDone`] and — with adaptive tuning on —
113    /// [`NativeEvent::Tuning`].
114    pub async fn download_file(
115        &self,
116        remote: &str,
117        dest: impl AsRef<Path>,
118    ) -> Result<u64, LibfwError> {
119        validate_relative(remote)?;
120        let dest = dest.as_ref();
121        let params = self.prepare_transfer(TransferKind::Download).await?;
122
123        let mut attempt = self.download_once(remote, dest, params.compress_level, false);
124        let mut outcome = attempt.await;
125        if let Err(e) = &outcome {
126            if super::is_restart_err(e) {
127                self.log(format!(
128                    "`{remote}` changed or shrank on the server; restarting from byte 0"
129                ));
130                attempt = self.download_once(remote, dest, params.compress_level, true);
131                outcome = attempt.await;
132            }
133        }
134        self.finish_transfer(outcome.is_ok());
135        outcome
136    }
137
138    /// One download pass. `force_restart` ignores any persisted resume offset.
139    async fn download_once(
140        &self,
141        remote: &str,
142        dest: &Path,
143        level: i32,
144        force_restart: bool,
145    ) -> Result<u64, LibfwError> {
146        let (etag, size) = self.fetch_meta(remote).await?;
147
148        // Resume only when the remote is the *same version* as the partial we
149        // hold: a different ETag means the bytes on disk are unrelated.
150        let resume = load_resume(dest).filter(|r| !force_restart && r.etag == etag);
151        let local_len = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
152        let start = match resume {
153            Some(state) if state.offset <= size && local_len >= state.offset => state.offset,
154            _ => 0,
155        };
156        if start > 0 {
157            self.log(format!("resuming `{remote}` at byte {start} of {size}"));
158        }
159
160        self.emit(NativeEvent::FileStart {
161            path: remote.to_string(),
162            size,
163        });
164
165        if start == size {
166            // Empty file, or already fully on disk.
167            self.emit(NativeEvent::FileDone {
168                path: remote.to_string(),
169                bytes: 0,
170            });
171            return Ok(0);
172        }
173
174        if let Some(parent) = dest.parent() {
175            tokio::fs::create_dir_all(parent)
176                .await
177                .map_err(|e| LibfwError::Storage(format!("create dir {}: {e}", parent.display())))?;
178        }
179        let mut file = tokio::fs::OpenOptions::new()
180            .create(true)
181            .read(true)
182            .write(true)
183            .truncate(false)
184            .open(dest)
185            .await
186            .map_err(|e| LibfwError::Storage(format!("open {}: {e}", dest.display())))?;
187
188        // Small files and short tails stream in one request; everything else
189        // is fetched in bounded batches so the tuned window/chunk size can be
190        // re-read mid-file (see `should_chunked`).
191        let moved = if should_chunked(size, start) {
192            let mut cursor = start;
193            let mut moved = 0u64;
194            while cursor < size {
195                // Re-read the live parameters before every batch: the ramp may
196                // have moved them while the previous batch was in flight.
197                let window = self
198                    .with_tune(|t| t.params().download_window.max(1))
199                    .unwrap_or(self.config().download_window.max(1));
200                let chunk_size = self
201                    .with_tune(|t| t.params().chunk_size.max(1))
202                    .unwrap_or(self.config().chunk_size.max(1));
203                let end = cursor.saturating_add(batch_span(size - cursor, chunk_size, window));
204                let got = self
205                    .download_batch(
206                        remote, &etag, &mut file, cursor, end, size, moved, window, chunk_size,
207                        level, dest,
208                    )
209                    .await?;
210                if got == 0 {
211                    break;
212                }
213                cursor = cursor.saturating_add(got);
214                moved = moved.saturating_add(got);
215                // Persist progress once per batch (an atomic write, but not a
216                // free one).
217                let _ = save_resume(dest, &etag, cursor);
218            }
219            moved
220        } else {
221            self.download_sequential(remote, &etag, &mut file, start, size, level, dest)
222                .await?
223        };
224
225        // A resumed file may still carry bytes beyond EOF (the remote shrank
226        // but kept the same ETag — impossible in practice, cheap to enforce).
227        file.set_len(size)
228            .await
229            .map_err(|e| LibfwError::Storage(format!("truncate {}: {e}", dest.display())))?;
230        file.flush()
231            .await
232            .map_err(|e| LibfwError::Storage(format!("flush {}: {e}", dest.display())))?;
233
234        let computed = start.saturating_add(moved);
235        if computed != size {
236            return Err(LibfwError::Protocol(format!(
237                "download of `{remote}` stopped at {computed} bytes, expected {size}"
238            )));
239        }
240        // Keep the completed offset so a repeated download is a no-op.
241        let _ = save_resume(dest, &etag, size);
242        self.emit(NativeEvent::FileDone {
243            path: remote.to_string(),
244            bytes: moved,
245        });
246        Ok(moved)
247    }
248
249    /// `HEAD /file/<path>` → `(etag, size)`.
250    pub(super) async fn fetch_meta(&self, remote: &str) -> Result<(String, u64), LibfwError> {
251        let url = self.file_url(remote);
252        let resp = self
253            .http()
254            .head(&url)
255            .headers(self.headers(false, None))
256            .send()
257            .await
258            .map_err(|e| LibfwError::Network(e.to_string()))?;
259        let status = resp.status().as_u16();
260        if status != 200 {
261            return Err(LibfwError::Http { status, url });
262        }
263        let etag = header_str(&resp, ETAG.as_str());
264        let size = header_str(&resp, CONTENT_LENGTH.as_str())
265            .parse::<u64>()
266            .ok()
267            .unwrap_or(0);
268        Ok((etag, size))
269    }
270
271    /// One batch: `window` concurrent range GETs over `[from, to)`, written in
272    /// order so the download can resume from a contiguous offset.
273    ///
274    /// `total` is the whole file size and `done_base` the bytes this run has
275    /// already written, so progress stays meaningful across batches.
276    #[allow(clippy::too_many_arguments)]
277    async fn download_batch(
278        &self,
279        remote: &str,
280        etag: &str,
281        file: &mut tokio::fs::File,
282        from: u64,
283        to: u64,
284        total: u64,
285        done_base: u64,
286        window: usize,
287        chunk_size: u64,
288        level: i32,
289        dest: &Path,
290    ) -> Result<u64, LibfwError> {
291        let chunks = parallel_chunks(from, to, chunk_size);
292        let remote_owned = remote.to_string();
293        let etag_owned = etag.to_string();
294        let mut stream = futures::stream::iter(chunks.into_iter().map(|(s, e)| {
295            let remote = remote_owned.clone();
296            let etag = etag_owned.clone();
297            async move {
298                let (data, rtt) = self
299                    .get_range_with_retry(&remote, &etag, s, e, level)
300                    .await?;
301                Ok::<_, LibfwError>((s, data, rtt))
302            }
303        }))
304        .buffer_unordered(window);
305
306        let mut pending: BTreeMap<u64, Vec<u8>> = BTreeMap::new();
307        let mut cursor = from;
308        let mut moved = 0u64;
309        let mut last_saved = from;
310
311        while let Some(result) = stream.next().await {
312            let (chunk_start, data, rtt) = match result {
313                Ok(part) => part,
314                Err(e) => {
315                    // Record the failure so the engine can shrink parameters,
316                    // then surface it (the caller may restart the file).
317                    self.tune_tick(done_base.saturating_add(moved), None, true);
318                    return Err(e);
319                }
320            };
321            pending.insert(chunk_start, data);
322            // Flush everything that is now contiguous.
323            while let Some(data) = pending.remove(&cursor) {
324                file.seek(std::io::SeekFrom::Start(cursor))
325                    .await
326                    .map_err(|e| LibfwError::Storage(format!("seek: {e}")))?;
327                file.write_all(&data)
328                    .await
329                    .map_err(|e| LibfwError::Storage(format!("write: {e}")))?;
330                cursor = cursor.saturating_add(data.len() as u64);
331                moved = moved.saturating_add(data.len() as u64);
332                self.emit(NativeEvent::Progress {
333                    done: done_base.saturating_add(moved),
334                    total,
335                });
336                if cursor >= last_saved.saturating_add(RESUME_SAVE_EVERY) {
337                    last_saved = cursor;
338                    let _ = save_resume(dest, etag, cursor);
339                }
340                self.tune_tick(done_base.saturating_add(moved), Some(rtt), false);
341            }
342        }
343        Ok(moved)
344    }
345
346    /// Single-connection download of `[start, size)`, streamed and
347    /// decompressed incrementally so memory stays bounded.
348    #[allow(clippy::too_many_arguments)]
349    async fn download_sequential(
350        &self,
351        remote: &str,
352        etag: &str,
353        file: &mut tokio::fs::File,
354        start: u64,
355        size: u64,
356        level: i32,
357        dest: &Path,
358    ) -> Result<u64, LibfwError> {
359        let (resp, rtt) = self
360            .range_request(remote, etag, start, size, level)
361            .await?;
362        let format = compression_format(&resp);
363        let mut dec = decompressor(format);
364        let mut cursor = start;
365        let mut moved = 0u64;
366        let mut last_saved = 0u64;
367        let mut body = resp.bytes_stream();
368        let mut out = Vec::new();
369
370        while let Some(chunk) = body.next().await {
371            let chunk = chunk.map_err(|e| LibfwError::Network(e.to_string()))?;
372            out.clear();
373            dec.decompress(&chunk, &mut out)
374                .map_err(|e| LibfwError::Decompress(e.to_string()))?;
375            if out.is_empty() {
376                continue;
377            }
378            file.seek(std::io::SeekFrom::Start(cursor))
379                .await
380                .map_err(|e| LibfwError::Storage(format!("seek: {e}")))?;
381            file.write_all(&out)
382                .await
383                .map_err(|e| LibfwError::Storage(format!("write: {e}")))?;
384            cursor = cursor.saturating_add(out.len() as u64);
385            moved = moved.saturating_add(out.len() as u64);
386            if cursor >= last_saved.saturating_add(RESUME_SAVE_EVERY) {
387                last_saved = cursor;
388                let _ = save_resume(dest, etag, cursor);
389            }
390            self.emit(NativeEvent::Progress {
391                done: moved,
392                total: size,
393            });
394            self.tune_tick(moved, Some(rtt), false);
395        }
396
397        out.clear();
398        dec.finish(&mut out)
399            .map_err(|e| LibfwError::Decompress(e.to_string()))?;
400        if !out.is_empty() {
401            file.seek(std::io::SeekFrom::Start(cursor))
402                .await
403                .map_err(|e| LibfwError::Storage(format!("seek: {e}")))?;
404            file.write_all(&out)
405                .await
406                .map_err(|e| LibfwError::Storage(format!("write: {e}")))?;
407            moved = moved.saturating_add(out.len() as u64);
408        }
409        Ok(moved)
410    }
411
412    /// One range GET with per-chunk exponential-backoff retries.
413    async fn get_range_with_retry(
414        &self,
415        remote: &str,
416        etag: &str,
417        start: u64,
418        end: u64,
419        level: i32,
420    ) -> Result<(Vec<u8>, f64), LibfwError> {
421        let mut attempt = 0u32;
422        loop {
423            match self.range_to_vec(remote, etag, start, end, level).await {
424                Ok((data, rtt)) => {
425                    if data.len() as u64 != end - start {
426                        return Err(LibfwError::Protocol(format!(
427                            "chunk {start}..{end} of `{remote}` yielded {} bytes, expected {}",
428                            data.len(),
429                            end - start
430                        )));
431                    }
432                    return Ok((data, rtt));
433                }
434                // "File changed" — retrying is pointless; restart instead.
435                Err(e) if super::is_restart_err(&e) => return Err(e),
436                Err(e) => {
437                    if attempt >= self.config().max_retries {
438                        return Err(e);
439                    }
440                    attempt += 1;
441                    self.log(format!(
442                        "retrying chunk {start}..{end} of `{remote}` (attempt {attempt}): {e}"
443                    ));
444                    tokio::time::sleep(self.backoff(attempt)).await;
445                }
446            }
447        }
448    }
449
450    /// `GET` one range and collect it into memory (parallel path).
451    async fn range_to_vec(
452        &self,
453        remote: &str,
454        etag: &str,
455        start: u64,
456        end: u64,
457        level: i32,
458    ) -> Result<(Vec<u8>, f64), LibfwError> {
459        let (resp, rtt) = self.range_request(remote, etag, start, end, level).await?;
460        let format = compression_format(&resp);
461        let mut dec = decompressor(format);
462        let body = resp
463            .bytes()
464            .await
465            .map_err(|e| LibfwError::Network(e.to_string()))?;
466        let mut out = Vec::new();
467        dec.decompress(&body, &mut out)
468            .map_err(|e| LibfwError::Decompress(e.to_string()))?;
469        dec.finish(&mut out)
470            .map_err(|e| LibfwError::Decompress(e.to_string()))?;
471        Ok((out, rtt))
472    }
473
474    /// Issue a `Range` GET, returning the response (206 only) plus its TTFB.
475    async fn range_request(
476        &self,
477        remote: &str,
478        etag: &str,
479        start: u64,
480        end: u64,
481        level: i32,
482    ) -> Result<(reqwest::Response, f64), LibfwError> {
483        let url = self.file_url(remote);
484        let last = end.saturating_sub(1);
485        let mut request = self
486            .http()
487            .get(&url)
488            .headers(self.headers(self.config().compress, Some(level)))
489            .header(RANGE, format!("bytes={start}-{last}"));
490        if !etag.is_empty() {
491            request = request.header(IF_RANGE, etag);
492        }
493        let t0 = now_ms();
494        let resp = request
495            .send()
496            .await
497            .map_err(|e| LibfwError::Network(e.to_string()))?;
498        let rtt = now_ms() - t0;
499        match resp.status() {
500            StatusCode::PARTIAL_CONTENT => Ok((resp, rtt)),
501            // 200 to a ranged request means the file changed under us; 416
502            // means it shrank. Both are "restart from byte 0".
503            other => Err(LibfwError::Http {
504                status: other.as_u16(),
505                url,
506            }),
507        }
508    }
509}
510
511/// The wire compression format of a response.
512fn compression_format(resp: &reqwest::Response) -> CompressionFormat {
513    resp.headers()
514        .get(HEADER_COMPRESS)
515        .and_then(|v| v.to_str().ok())
516        .and_then(CompressionFormat::parse_header)
517        .unwrap_or(CompressionFormat::None)
518}
519
520/// Read a response header as a `String` (empty when absent/not ASCII).
521fn header_str(resp: &reqwest::Response, name: &str) -> String {
522    resp.headers()
523        .get(name)
524        .and_then(|v| v.to_str().ok())
525        .unwrap_or_default()
526        .to_string()
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn parallel_chunks_cover_the_tail_exactly() {
535        assert_eq!(
536            parallel_chunks(0, 10, 4),
537            vec![(0, 4), (4, 8), (8, 10)]
538        );
539        assert_eq!(parallel_chunks(5, 10, 4), vec![(5, 9), (9, 10)]);
540        assert!(parallel_chunks(10, 10, 4).is_empty());
541        // A zero chunk size falls back to the protocol default.
542        let chunks = parallel_chunks(0, 10, 0);
543        assert_eq!(chunks, vec![(0, 10)]);
544    }
545
546    #[test]
547    fn should_chunked_is_window_independent_and_ignores_small_tails() {
548        let big = MIN_PARALLEL_DOWNLOAD_BYTES * 2;
549        assert!(should_chunked(big, 0));
550        // Window 1 is not known here on purpose: the batch loop re-reads the
551        // live window per batch, so a minimal start still fetches bounded
552        // chunks (regression: one whole-file 206 that could never be tuned).
553        assert!(!should_chunked(1024, 0), "small files stay sequential");
554        assert!(
555            !should_chunked(MIN_PARALLEL_DOWNLOAD_BYTES, MIN_PARALLEL_DOWNLOAD_BYTES - 1),
556            "the remaining tail must also be large"
557        );
558    }
559
560    #[test]
561    fn batch_span_is_bounded_and_never_zero() {
562        // window × chunk, clamped to what is left.
563        assert_eq!(batch_span(10 * 1024 * 1024, 256 * 1024, 4), 1024 * 1024);
564        assert_eq!(batch_span(300 * 1024, 256 * 1024, 4), 300 * 1024);
565        // Junk (window 0 / chunk 0) must still make progress.
566        assert_eq!(batch_span(1024, 0, 0), 1);
567    }
568
569    #[test]
570    fn resume_sidecar_round_trips() {
571        let dir = tempfile::tempdir().unwrap();
572        let dest = dir.path().join("file.bin");
573        assert!(load_resume(&dest).is_none());
574        save_resume(&dest, "\"abc\"", 1234).unwrap();
575        let state = load_resume(&dest).unwrap();
576        assert_eq!(state.etag, "\"abc\"");
577        assert_eq!(state.offset, 1234);
578        assert!(resume_path(&dest).exists());
579    }
580}