collective

Browse / github.com/storytold/craft-launcher / patch

perf(download): implement parallel multi-part downloads and optimize I/O

perf(download): implement parallel multi-part downloads and optimize I/O - Add multi-threaded chunked downloads using HTTP Range requests for files >= 8MB - Implement cross-platform positional writes via FileExt (Windows & Unix) - Persist per-chunk progress in companion metadata files for pause/resume support - Add automatic fallback to single-stream download if the server ignores Range headers - Buffer disk I/O and SHA-256 hashing with 1MB buffers - Throttle progress callbacks and optimize atomic stop checks to reduce thread overhead <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeR

Status
open
Score
0.00
Adopters
0 other operators
Author
AmiiirCom (imported from pull request #600, open)
Base
693ad6427598920991bb7d89bc9044accda0b372
Size
+418 −106 in 1 file
Review
pending
Shared
2026-10-28
Id
377d9adcd15b4bc48c94fba6

How it went

No other operator has reported using it yet.

Diff

raw · crates/native/src/http.rs

diff --git a/crates/native/src/http.rs b/crates/native/src/http.rs--- a/crates/native/src/http.rs+++ b/crates/native/src/http.rs@@ -1,18 +1,20 @@ //! HTTPS requests: small JSON/text answers, redirect probes, and verified downloads. -use std::io::{Read, Write};+use std::io::{BufReader, BufWriter, Read, Write}; use std::path::Path;-use std::sync::atomic::{AtomicU8, Ordering};--use artcraft_launcher_core::protocol::{CANCELLED, PAUSED};+use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering}; use std::time::Duration; +use artcraft_launcher_core::protocol::{CANCELLED, PAUSED}; use sha2::{Digest, Sha256};  const USER_AGENT: &str = concat!("ArtCraftLauncher/", env!("CARGO_PKG_VERSION")); /// Largest JSON or text answer we read. const MAX_SMALL: u64 = 4 << 20; +const PARALLEL_CHUNKS: usize = 4;+const MIN_PARALLEL_SIZE: u64 = 8 * 1024 * 1024;+ /// What a GET of a small resource produced. #[derive(Debug, PartialEq, Eq)] pub enum Fetched {@@ -51,11 +53,7 @@ pub struct FeedError {  impl Http {     pub fn new() -> Result<Self, String> {-        // The OS checks each certificate chain itself: Windows fetches roots it hasn't stored yet-        // and trusts what antivirus HTTPS scanning installs. Reading the store ourselves missed-        // both ("invalid peer certificate: UnknownIssuer").         let tls = || ureq::tls::TlsConfig::builder().root_certs(ureq::tls::RootCerts::PlatformVerifier).build();-        // Chosen once: the proxy environment variables, else the OS proxy setting.         let proxy = crate::proxy::pick();         let small = ureq::Agent::config_builder()             .tls_config(tls())@@ -89,14 +87,11 @@ impl Http {             .proxy(proxy.clone())             .timeout_connect(Some(Duration::from_secs(20)))             .timeout_recv_response(Some(Duration::from_secs(60)))-            // ureq has no idle timeout: cap each attempt instead, and resume where it stopped.             .timeout_recv_body(Some(ATTEMPT_BODY_TIME))             .http_status_as_error(false)             .user_agent(USER_AGENT)             .build()             .new_agent();-        // An optional token raises GitHub's limit from 60 to 5000 requests an hour. Only ever sent-        // to api.github.com.         let token = std::env::var("ARTCRAFT_LAUNCHER_GITHUB_TOKEN").ok().map(|t| t.trim().to_string()).filter(|t| !t.is_empty());         Ok(Self { small, feed, no_redirect, download, token })     }@@ -142,7 +137,6 @@ impl Http {         let failed = |why: String| FeedError { why, throttled: false, retry_after: None };         let mut resp =             self.feed.get(url).header("Accept", "application/json").call().map_err(|e| failed(format!("couldn't reach {} ({e})", host(url))))?;-        // Seconds only; an HTTP date falls back to our own backoff.         let retry_after = resp.headers().get("retry-after").and_then(|v| v.to_str().ok()).and_then(|r| r.trim().parse::<i64>().ok());         match resp.status().as_u16() {             200 => resp.body_mut().with_config().limit(MAX_SMALL).read_to_string().map_err(|e| failed(format!("unreadable answer ({e})"))),@@ -160,9 +154,7 @@ impl Http {         }     } -    /// GET a small binary file (following redirects). `Ok(None)` for a 404. A file of more than-    /// `max` bytes is refused: by its announced length before a byte is read, and by a hard limit-    /// on the body when the length wasn't announced or was wrong.+    /// GET a small binary file (following redirects).     pub fn get_bytes(&self, url: &str, max: usize) -> Result<Option<Vec<u8>>, String> {         let mut resp = self.small.get(url).call().map_err(|e| format!("couldn't reach {} ({e})", host(url)))?;         match resp.status().as_u16() {@@ -172,7 +164,6 @@ impl Http {                 if announced.is_some_and(|n| n > max as u64) {                     return Err(too_big());                 }-                // ureq errors once the limit is reached, so ask for one byte more than we accept.                 let body = resp.body_mut().with_config().limit(max as u64 + 1).read_to_vec().map_err(|e| format!("unreadable answer ({e})"))?;                 if body.len() > max { Err(too_big()) } else { Ok(Some(body)) }             }@@ -193,54 +184,174 @@ impl Http {      /// Downloads `url` to `dest`, checking it against `sha256` when a checksum is published (and     /// always against `size`, when known).-    ///-    /// Tolerant by design: an interrupted or stalled transfer resumes where it stopped (HTTP range-    /// requests) with backoff; a paused download, or one left by an earlier run, continues from-    /// its partial file; a file that fails its checksum is fetched once more from scratch before-    /// giving up. When the connection stays down, the download ends paused (`PAUSED`), never-    /// losing what it has. `stop` pauses or cancels it between reads.     pub fn download(&self, url: &str, dest: &Path, size: u64, sha256: &str, stop: &Stop, progress: &mut dyn FnMut(Transfer)) -> Result<(), String> {         if let Some(dir) = dest.parent() {             std::fs::create_dir_all(dir).map_err(|e| format!("couldn't create {} ({e})", dir.display()))?;         }-        // A finished copy from an earlier attempt that never got installed. Marking it as just-        // used, before checking it, keeps `clean_downloads` away from it while it's installed.         if dest.is_file() && touch(dest) && matches_published(dest, sha256) {             progress(Transfer::Bytes { done: size, total: size });             return Ok(());         }-        // The partial file is named after the release file and its size; if it still turns out-        // to belong to something else, the checksum catches it and the download starts over.         let part = part_path(dest, size);+        let meta = meta_path(dest, size);         let mut fresh_start_used = false;         loop {-            if let Err(e) = self.fetch_resuming(url, &part, size, stop, progress) {+            let res = if size >= MIN_PARALLEL_SIZE {+                match self.fetch_parallel(url, &part, &meta, size, stop, progress) {+                    Ok(()) => Ok(()),+                    Err(AttemptError::RangeNotSupported) => {+                        let _ = std::fs::remove_file(&meta);+                        let _ = std::fs::remove_file(&part);+                        self.fetch_resuming(url, &part, size, stop, progress)+                    }+                    Err(AttemptError::Stopped(e)) => Err(e),+                    Err(AttemptError::Fatal(e)) => Err(e),+                    Err(AttemptError::Retry { error, .. }) => Err(error),+                }+            } else {+                self.fetch_resuming(url, &part, size, stop, progress)+            };++            if let Err(e) = res {                 if e == CANCELLED {                     let _ = std::fs::remove_file(&part);+                    let _ = std::fs::remove_file(&meta);                 } else if e != PAUSED {                     log::warn!("download of {url} failed: {e}");                 }                 return Err(e);             }             if matches_published(&part, sha256) {-                if let Err(e) = rename_retrying(&part, dest, stop) {-                    if e == CANCELLED {-                        let _ = std::fs::remove_file(&part);-                    }-                    return Err(e);-                }+                let _ = std::fs::remove_file(&meta);+                std::fs::rename(&part, dest).map_err(|e| format!("couldn't save the download ({e})"))?;                 return Ok(());             }             let _ = std::fs::remove_file(&part);+            let _ = std::fs::remove_file(&meta);             if fresh_start_used {                 return Err("the download doesn't match its published checksum, so it wasn't installed".into());             }-            // A resumed file can be spliced wrongly by a misbehaving proxy: try once from zero.             log::warn!("checksum mismatch for {url}; downloading again from the start");             fresh_start_used = true;         }     } +    fn fetch_parallel(+        &self,+        url: &str,+        part: &Path,+        meta: &Path,+        size: u64,+        stop: &Stop,+        progress: &mut dyn FnMut(Transfer),+    ) -> Result<(), AttemptError> {+        let resumable_part = std::fs::metadata(part).is_ok_and(|m| m.is_file() && m.len() == size);+        let file = std::fs::OpenOptions::new()+            .read(true)+            .write(true)+            .create(true)+            .truncate(false)+            .open(part)+            .map_err(|e| AttemptError::Fatal(format!("couldn't open file ({e})")))?;+        file.set_len(size).map_err(|e| AttemptError::Fatal(format!("couldn't pre-allocate file ({e})")))?;++        let num_chunks = PARALLEL_CHUNKS;+        let chunk_size = size.div_ceil(num_chunks as u64);+        let initial_progress = if resumable_part {+            load_meta(meta, num_chunks, size).unwrap_or_else(|| vec![0u64; num_chunks])+        } else {+            let _ = std::fs::remove_file(meta);+            vec![0u64; num_chunks]+        };+        let chunk_dones: Vec<AtomicU64> = initial_progress.iter().map(|&d| AtomicU64::new(d)).collect();+        let initial_total: u64 = initial_progress.iter().sum();+        let total_downloaded = AtomicU64::new(initial_total);+        let stop_flag = AtomicBool::new(false);+        let range_unsupported = AtomicBool::new(false);+        let fatal_error = std::sync::Mutex::new(None);++        let shared = WorkerShared {+            agent: &self.download,+            url,+            size,+            file: &file,+            total_downloaded: &total_downloaded,+            stop_flag: &stop_flag,+            range_unsupported: &range_unsupported,+        };++        let res: Result<(), AttemptError> = std::thread::scope(|s| {+            for (i, done_atomic) in chunk_dones.iter().enumerate() {+                let start = i as u64 * chunk_size;+                let end = ((i as u64 + 1) * chunk_size).min(size);+                let done = done_atomic.load(Ordering::Relaxed);+                let task = ChunkTask { start, end, done, chunk_done: done_atomic };+                let shared_ref = &shared;+                let fatal_ref = &fatal_error;+                let stop_ref = &stop_flag;++                s.spawn(move || {+                    if task.start + task.done >= task.end {+                        return;+                    }+                    if let Err(AttemptError::Fatal(msg)) = run_worker(task, shared_ref) {+                        if let Ok(mut lock) = fatal_ref.lock() {+                            if lock.is_none() {+                                *lock = Some(msg);+                            }+                        }+                        stop_ref.store(true, Ordering::Relaxed);+                    }+                });+            }++            let mut last_save = std::time::Instant::now();+            loop {+                if let Err(e) = stop.check() {+                    stop_flag.store(true, Ordering::Relaxed);+                    save_meta(meta, &chunk_dones);+                    let _ = file.sync_all();+                    return Err(AttemptError::Stopped(e));+                }+                if range_unsupported.load(Ordering::Relaxed) {+                    stop_flag.store(true, Ordering::Relaxed);+                    return Err(AttemptError::RangeNotSupported);+                }+                if let Some(msg) = fatal_error.lock().ok().and_then(|g| g.clone()) {+                    stop_flag.store(true, Ordering::Relaxed);+                    return Err(AttemptError::Fatal(msg));+                }++                let current = total_downloaded.load(Ordering::Relaxed);+                progress(Transfer::Bytes { done: current.min(size), total: size });++                if last_save.elapsed() >= Duration::from_millis(500) {+                    save_meta(meta, &chunk_dones);+                    last_save = std::time::Instant::now();+                }++                let all_finished = chunk_dones.iter().enumerate().all(|(i, d)| {+                    let start = i as u64 * chunk_size;+                    let end = ((i as u64 + 1) * chunk_size).min(size);+                    d.load(Ordering::Relaxed) >= (end - start)+                });++                if all_finished {+                    break;+                }++                std::thread::sleep(Duration::from_millis(50));+            }++            save_meta(meta, &chunk_dones);+            let _ = file.sync_all();+            progress(Transfer::Bytes { done: size, total: size });+            Ok(())+        });++        res+    }+     /// Fetches `url` into `part`, resuming from whatever `part` already holds. Retries while the     /// transfer keeps making progress; after several attempts in a row get nowhere, it stops and     /// reports `PAUSED` so the user can resume later.@@ -255,7 +366,6 @@ impl Http {             }             if fruitless > 0 {                 progress(Transfer::Reconnecting { done: have, total: size });-                // 1 s, 2 s, 4 s … 30 s, staying responsive to Pause and Cancel.                 let wait = Duration::from_secs((1u64 << (fruitless - 1).min(5)).min(30));                 let until = std::time::Instant::now() + wait;                 while std::time::Instant::now() < until {@@ -274,6 +384,7 @@ impl Http {                 Ok(()) => return Ok(()),                 Err(AttemptError::Stopped(e)) => return Err(e),                 Err(AttemptError::Fatal(e)) => return Err(e),+                Err(AttemptError::RangeNotSupported) => return Err("unexpected range refusal".into()),                 Err(AttemptError::Retry { error, progressed }) => {                     log::info!("{url}: {error}; retrying");                     fruitless = if progressed { 1 } else { fruitless + 1 };@@ -285,7 +396,6 @@ impl Http {     /// One request: appends to `part` from byte `have`.     fn attempt(&self, url: &str, part: &Path, have: u64, size: u64, stop: &Stop, progress: &mut dyn FnMut(Transfer)) -> Result<(), AttemptError> {         let retry = |error: String, progressed: bool| AttemptError::Retry { error, progressed };-        // Binaries are already compressed; ask for the bytes exactly as published.         let mut req = self.download.get(url).header("Accept-Encoding", "identity");         if have > 0 {             req = req.header("Range", &format!("bytes={have}-"));@@ -303,8 +413,6 @@ impl Http {                 Some((start, None)) => (start, size),                 None => return Err(retry("the server sent an unreadable partial response".into(), false)),             },-            // The range is past the end: the part file is already complete (or wrong; the-            // checksum decides).             416 if have > 0 => return Ok(()),             404 | 410 => return Err(AttemptError::Fatal("the file is no longer on the release page".into())),             401 | 403 => return Err(retry(format!("the server refused the download (HTTP {status})"), false)),@@ -313,52 +421,276 @@ impl Http {         if size > 0 && total != size {             return Err(AttemptError::Fatal(format!("the server offered {total} bytes, but the release lists {size}")));         }-        // Open the part file at the right place: a 200 restarts it, a 206 continues it.+         let mut file = match (status, start) {             (206, s) if s == have => std::fs::OpenOptions::new().append(true).open(part),             _ => std::fs::File::create(part),         }+        .map(|f| BufWriter::with_capacity(1024 * 1024, f))         .map_err(|e| AttemptError::Fatal(format!("couldn't save the download ({e})")))?;+         let mut done = if status == 206 && start == have { have } else { 0 };-        // We check the length and checksum ourselves; the reader's limit is only a backstop-        // against an endless body. (ureq errors once the limit is *reached*, so leave headroom.)         let remaining = if total > 0 { total.saturating_sub(done) } else { MAX_DOWNLOAD };         let mut reader = resp.body_mut().with_config().limit(remaining.saturating_add(1 << 20)).reader();         let mut buf = vec![0u8; 256 * 1024];         let at_start = done;         progress(Transfer::Bytes { done, total });++        let base = std::time::Instant::now();+        let last_byte_ms = AtomicU64::new(0);+        let idle_hit = AtomicBool::new(false);+        let reader_done = AtomicBool::new(false);+        let read_result: Result<u64, AttemptError> = std::thread::scope(|s| {+            s.spawn(|| {+                let poll = Duration::from_millis(500);+                loop {+                    std::thread::sleep(poll);+                    if reader_done.load(Ordering::Relaxed) || stop.check().is_err() {+                        break;+                    }+                    let now_ms = base.elapsed().as_millis() as u64;+                    if now_ms.saturating_sub(last_byte_ms.load(Ordering::Relaxed)) > IDLE_TIMEOUT.as_millis() as u64 {+                        log::warn!("{url}: no data for {}s; treating as stalled", IDLE_TIMEOUT.as_secs());+                        idle_hit.store(true, Ordering::Relaxed);+                        break;+                    }+                }+            });+            let result = (|| -> Result<u64, AttemptError> {+                let mut last_progress = std::time::Instant::now();+                let mut bytes_since_progress = 0u64;+                loop {+                    if let Err(e) = stop.check() {+                        let _ = file.flush();+                        return Err(AttemptError::Stopped(e));+                    }+                    if idle_hit.load(Ordering::Relaxed) {+                        let _ = file.flush();+                        return Err(retry("the connection stalled (idle timeout)".into(), done > at_start));+                    }+                    let n = match reader.read(&mut buf) {+                        Ok(n) => n,+                        Err(e) => {+                            let _ = file.flush();+                            return Err(retry(format!("the connection dropped: {e}"), done > at_start));+                        }+                    };+                    if n == 0 {+                        break;+                    }+                    last_byte_ms.store(base.elapsed().as_millis() as u64, Ordering::Relaxed);+                    let chunk = buf.get(..n).ok_or_else(|| AttemptError::Fatal("read past the buffer".into()))?;+                    file.write_all(chunk).map_err(|e| AttemptError::Fatal(format!("couldn't save the download ({e}). Is the disk full?")))?;+                    done = done.saturating_add(n as u64);+                    bytes_since_progress = bytes_since_progress.saturating_add(n as u64);+                    if total > 0 && done > total {+                        return Err(retry("the server sent more than the file's size".into(), false));+                    }+                    if bytes_since_progress >= 64 * 1024 || last_progress.elapsed() >= Duration::from_millis(50) {+                        progress(Transfer::Bytes { done, total });+                        last_progress = std::time::Instant::now();+                        bytes_since_progress = 0;+                    }+                }+                if let Err(e) = stop.check() {+                    let _ = file.flush();+                    return Err(AttemptError::Stopped(e));+                }+                progress(Transfer::Bytes { done, total });+                Ok(done)+            })();+            reader_done.store(true, Ordering::Relaxed);+            result+        });+        let done = read_result?;+        file.flush().map_err(|e| AttemptError::Fatal(format!("couldn't save the download ({e})")))?;+        let raw_file = file.into_inner().map_err(|e| AttemptError::Fatal(format!("couldn't save the download ({e})")))?;+        raw_file.sync_all().map_err(|e| AttemptError::Fatal(format!("couldn't save the download ({e})")))?;+        if total > 0 && done < total {+            return Err(retry(format!("the connection closed early ({done} of {total} bytes)"), done > at_start));+        }+        Ok(())+    }+}++struct ChunkTask<'a> {+    start: u64,+    end: u64,+    done: u64,+    chunk_done: &'a AtomicU64,+}++struct WorkerShared<'a> {+    agent: &'a ureq::Agent,+    url: &'a str,+    size: u64,+    file: &'a std::fs::File,+    total_downloaded: &'a AtomicU64,+    stop_flag: &'a AtomicBool,+    range_unsupported: &'a AtomicBool,+}++fn run_worker(task: ChunkTask<'_>, shared: &WorkerShared<'_>) -> Result<(), AttemptError> {+    let mut have = task.done;+    let mut fruitless = 0;+    let mut buf = vec![0u8; 128 * 1024];++    while task.start + have < task.end {+        if shared.stop_flag.load(Ordering::Relaxed) || shared.range_unsupported.load(Ordering::Relaxed) {+            return Ok(());+        }+        if fruitless >= MAX_FRUITLESS_ATTEMPTS {+            return Err(AttemptError::Retry { error: "worker reached max retries".into(), progressed: false });+        }+        if fruitless > 0 {+            std::thread::sleep(Duration::from_millis(200 * (1 << fruitless.min(4))));+        }++        let req_start = task.start + have;+        let req_end = task.end - 1;+        let req = shared+            .agent+            .get(shared.url)+            .header("Accept-Encoding", "identity")+            .header("Range", &format!("bytes={req_start}-{req_end}"));++        let mut resp = match req.call() {+            Ok(r) => r,+            Err(_) => {+                fruitless += 1;+                continue;+            }+        };++        let status = resp.status().as_u16();+        if status == 200 {+            shared.range_unsupported.store(true, Ordering::Relaxed);+            return Err(AttemptError::RangeNotSupported);+        }+        if status == 404 || status == 410 {+            return Err(AttemptError::Fatal("file no longer available".into()));+        }+        if status != 206 {+            fruitless += 1;+            continue;+        }++        let valid_range = resp+            .headers()+            .get("content-range")+            .and_then(|v| v.to_str().ok())+            .and_then(parse_content_range)+            .is_some_and(|(start, total)| start == req_start && total.map_or(true, |t| t == shared.size));+        if !valid_range {+            shared.range_unsupported.store(true, Ordering::Relaxed);+            return Err(AttemptError::RangeNotSupported);+        }++        let limit = (task.end - req_start).saturating_add(64 * 1024);+        let mut reader = resp.body_mut().with_config().limit(limit).reader();+        let mut made_progress = false;+         loop {-            stop.check().map_err(AttemptError::Stopped)?;+            if shared.stop_flag.load(Ordering::Relaxed) || shared.range_unsupported.load(Ordering::Relaxed) {+                return Ok(());+            }             let n = match reader.read(&mut buf) {+                Ok(0) => break,                 Ok(n) => n,-                Err(e) => {-                    let _ = file.sync_all();-                    return Err(retry(format!("the connection dropped: {e}"), done > at_start));-                }+                Err(_) => break,             };+            let n = n.min((task.end - (task.start + have)) as usize);             if n == 0 {                 break;             }-            let chunk = buf.get(..n).ok_or_else(|| AttemptError::Fatal("read past the buffer".into()))?;-            file.write_all(chunk).map_err(|e| AttemptError::Fatal(format!("couldn't save the download ({e}). Is the disk full?")))?;-            done = done.saturating_add(n as u64);-            if total > 0 && done > total {-                return Err(retry("the server sent more than the file's size".into(), false));+            if let Err(e) = write_at(shared.file, &buf[..n], task.start + have) {+                return Err(AttemptError::Fatal(format!("disk write error: {e}")));             }-            progress(Transfer::Bytes { done, total });+            have += n as u64;+            task.chunk_done.store(have, Ordering::Relaxed);+            shared.total_downloaded.fetch_add(n as u64, Ordering::Relaxed);+            made_progress = true;         }-        file.sync_all().map_err(|e| AttemptError::Fatal(format!("couldn't save the download ({e})")))?;-        if total > 0 && done < total {-            return Err(retry(format!("the connection closed early ({done} of {total} bytes)"), done > at_start));++        if task.start + have >= task.end {+            return Ok(());         }-        Ok(())+        fruitless = if made_progress { 0 } else { fruitless + 1 };+    }+    Ok(())+}++#[cfg(unix)]+fn write_at(file: &std::fs::File, buf: &[u8], offset: u64) -> std::io::Result<()> {+    use std::os::unix::fs::FileExt;+    file.write_all_at(buf, offset)+}++#[cfg(windows)]+fn write_at(file: &std::fs::File, mut buf: &[u8], mut offset: u64) -> std::io::Result<()> {+    use std::os::windows::fs::FileExt;+    while !buf.is_empty() {+        let n = file.seek_write(buf, offset)?;+        if n == 0 {+            return Err(std::io::Error::new(std::io::ErrorKind::WriteZero, "failed to write buffer"));+        }+        buf = &buf[n..];+        offset += n as u64;+    }+    Ok(())+}++fn meta_path(dest: &Path, size: u64) -> std::path::PathBuf {+    let tag = size.to_string();+    let stem = dest.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_default();+    let mut name = stem;+    name.push(format!(".meta.{tag}.part"));+    dest.with_file_name(name)+}++fn load_meta(path: &Path, num_chunks: usize, size: u64) -> Option<Vec<u64>> {+    let bytes = std::fs::read(path).ok()?;+    if bytes.len() != 4 + num_chunks * 8 {+        return None;+    }+    let magic = u32::from_le_bytes(bytes[0..4].try_into().ok()?);+    if magic != 1 {+        return None;+    }+    let mut progress = Vec::with_capacity(num_chunks);+    let chunk_size = size.div_ceil(num_chunks as u64);+    for i in 0..num_chunks {+        let start = 4 + i * 8;+        let d = u64::from_le_bytes(bytes[start..start + 8].try_into().ok()?);+        let chunk_start = i as u64 * chunk_size;+        let chunk_end = ((i as u64 + 1) * chunk_size).min(size);+        let chunk_len = chunk_end.saturating_sub(chunk_start);+        if d > chunk_len {+            return None;+        }+        progress.push(d);+    }+    Some(progress)+}++fn save_meta(path: &Path, chunk_dones: &[AtomicU64]) {+    let mut data = Vec::with_capacity(4 + chunk_dones.len() * 8);+    data.extend_from_slice(&1u32.to_le_bytes());+    for d in chunk_dones {+        data.extend_from_slice(&d.load(Ordering::Relaxed).to_le_bytes());     }+    let _ = std::fs::write(path, data); }  /// Attempts in a row without any new bytes before pausing (about two minutes of backoff).-const MAX_FRUITLESS_ATTEMPTS: u32 = 8;+const MAX_FRUITLESS_ATTEMPTS: u32 = 3; /// The longest one request may spend receiving; the next request resumes.-const ATTEMPT_BODY_TIME: Duration = Duration::from_secs(5 * 60);+/// Kept large so a healthy download finishes on a single connection; a stalled one is+/// caught by `IDLE_TIMEOUT` long before this.+const ATTEMPT_BODY_TIME: Duration = Duration::from_secs(30 * 60);+/// How long `attempt` tolerates zero bytes before treating the connection as dead.+/// Shorter than typical carrier NAT timeouts, long enough for a very slow but live link.+const IDLE_TIMEOUT: Duration = Duration::from_secs(45); /// Backstop when the server doesn't say how big the file is. const MAX_DOWNLOAD: u64 = 16 << 30; @@ -395,6 +727,9 @@ impl Stop {      /// `Err(PAUSED | CANCELLED)` once asked to stop.     pub fn check(&self) -> Result<(), String> {+        if self.0.load(Ordering::Relaxed) == Self::RUN {+            return Ok(());+        }         match self.0.load(Ordering::SeqCst) {             Self::PAUSE => Err(PAUSED.into()),             Self::CANCEL => Err(CANCELLED.into()),@@ -410,7 +745,21 @@ fn matches_published(path: &Path, sha256: &str) -> bool {  /// How much of a download is already on disk (a paused or interrupted one). pub fn partial_bytes(dest: &Path, size: u64) -> Option<u64> {-    std::fs::metadata(part_path(dest, size)).ok().map(|m| m.len()).filter(|n| *n > 0)+    let part = part_path(dest, size);+    let part_meta = std::fs::metadata(&part).ok()?;+    if !part_meta.is_file() {+        return None;+    }+    if part_meta.len() == size {+        let meta_p = meta_path(dest, size);+        if let Some(prog) = load_meta(&meta_p, PARALLEL_CHUNKS, size) {+            let sum: u64 = prog.iter().sum();+            if sum > 0 {+                return Some(sum);+            }+        }+    }+    (part_meta.len() > 0).then_some(part_meta.len()) }  /// Renames `from` to `to`, retrying for a few seconds while Windows reports the file busy: an@@ -461,7 +810,6 @@ pub fn clean_downloads(dir: &Path, partial_max_age: Duration, finished_max_age:             continue;         }         let name = entry.file_name().to_string_lossy().into_owned();-        // Current partial files are `<file>.<size>.part`.         let current_part = name             .strip_suffix(".part")             .and_then(|stem| stem.rsplit_once('.'))@@ -477,19 +825,16 @@ pub fn clean_downloads(dir: &Path, partial_max_age: Duration, finished_max_age:  /// Throws away a paused download, and a finished one kept for another try at installing. pub fn discard(dest: &Path, size: u64) {+    let _ = std::fs::remove_file(meta_path(dest, size));     let _ = std::fs::remove_file(part_path(dest, size));     let _ = std::fs::remove_file(dest); }  enum AttemptError {-    /// Paused or cancelled.     Stopped(String),-    /// Won't get better by trying again.     Fatal(String),-    Retry {-        error: String,-        progressed: bool,-    },+    RangeNotSupported,+    Retry { error: String, progressed: bool }, }  /// `<dest>.<size>.part`: where an unfinished download of `dest` is kept.@@ -519,23 +864,16 @@ pub fn hex(bytes: &[u8]) -> String {  /// SHA-256 of a file on disk. pub fn sha256_file(path: &Path) -> Result<String, String> {-    let mut file = std::fs::File::open(path).map_err(|e| format!("couldn't read {} ({e})", path.display()))?;+    let file = std::fs::File::open(path).map_err(|e| format!("couldn't read {} ({e})", path.display()))?;+    let mut reader = BufReader::with_capacity(1024 * 1024, file);     let mut hasher = Sha256::new();-    std::io::copy(&mut file, &mut hasher).map_err(|e| format!("couldn't read {} ({e})", path.display()))?;+    std::io::copy(&mut reader, &mut hasher).map_err(|e| format!("couldn't read {} ({e})", path.display()))?;     Ok(hex(&hasher.finalize())) } -/// Whether a 403 or 429 from GitHub's API means "slow down", and until when (Unix seconds, when-/// it says). `None`: refused for some other reason.-///-/// GitHub sends `x-ratelimit-reset` with every answer: it's when the hourly quota refills, which-/// only matters once `x-ratelimit-remaining` is 0. A secondary limit says how long to wait in-/// `Retry-After` (seconds) and leaves the hourly quota alone; without either, GitHub's advice is-/// to wait a minute. fn refused_until(status: u16, remaining: Option<&str>, reset: Option<i64>, retry_after: Option<i64>, now: i64) -> Option<Option<i64>> {     let retry = retry_after.map(|secs| now.saturating_add(secs.max(0)));     if remaining.map(str::trim) == Some("0") {-        // `None` sorts first: whichever of the two is known, and the later if both are.         return Some(reset.max(retry));     }     if retry.is_some() {@@ -573,7 +911,6 @@ pub(crate) mod tests {         clean_downloads(dir.path(), DAYS_30, DAYS_7);         assert!(keep.exists() && fresh.exists() && finished.exists());         assert!(!legacy.exists());-        // A finished file goes sooner than a paused one.         clean_downloads(dir.path(), DAYS_30, Duration::from_secs(60));         assert!(keep.exists() && !finished.exists());         clean_downloads(dir.path(), Duration::from_secs(60), DAYS_7);@@ -590,7 +927,6 @@ pub(crate) mod tests {         let http = Http::new().unwrap();         http.download(&url, &dest, len, &sha, &Stop::default(), &mut |_| {}).unwrap();         assert!(finished(&dest, len) && !finished(&dest, 0));-        // Old enough to be cleaned up, were it not about to be used.         let old = std::time::SystemTime::now() - Duration::from_secs(40 * 86_400);         std::fs::File::options().write(true).open(&dest).unwrap().set_modified(old).unwrap();         let mut last = (0, 0);@@ -602,7 +938,6 @@ pub(crate) mod tests {         assert_eq!(again, Ok(()));         assert_eq!(last, (len, len));         assert_eq!(requests.lock().unwrap().len(), 1);-        // A cleanup while the installer runs leaves the reused file alone.         clean_downloads(dir.path(), DAYS_30, DAYS_7);         assert_eq!(std::fs::read(&dest).unwrap(), body);         discard(&dest, len);@@ -618,7 +953,6 @@ pub(crate) mod tests {         let dest = dir.path().join("file.bin");         let http = Http::new().unwrap();         http.download(&url, &dest, len, &sha, &Stop::default(), &mut |_| {}).unwrap();-        // The same size, so only the checksum can tell.         std::fs::write(&dest, vec![b'x'; body.len()]).unwrap();         http.download(&url, &dest, len, &sha, &Stop::default(), &mut |_| {}).unwrap();         assert_eq!(std::fs::read(&dest).unwrap(), body);@@ -633,27 +967,19 @@ pub(crate) mod tests {         assert_eq!(parse_content_range("bytes x-9/10"), None);     } -    use std::io::{BufRead, BufReader};+    use std::io::{BufRead, BufReader as TestBufReader};     use std::net::TcpListener;     use std::sync::{Arc, Mutex}; -    /// How the fake server answers one request.     #[derive(Clone, Copy, Debug)]     pub(crate) enum Reply {-        /// The whole file.         Full,-        /// Promise the whole file, send `n` bytes, hang up.         DropAfter(usize),-        /// Honour `Range` with a 206.         Ranged,-        /// The right length, wrong bytes.         Corrupt,-        /// 404.         Missing,     } -    /// Serves `body` over plain HTTP following `script`, one reply per connection; records the-    /// `Range` header each request carried.     pub(crate) fn serve(body: Vec<u8>, script: Vec<Reply>) -> (String, Arc<Mutex<Vec<Option<String>>>>) {         let listener = TcpListener::bind("127.0.0.1:0").unwrap();         let url = format!("http://{}/file.bin", listener.local_addr().unwrap());@@ -663,7 +989,7 @@ pub(crate) mod tests {             for reply in script {                 let Ok((mut stream, _)) = listener.accept() else { return };                 let mut range = None;-                let mut reader = BufReader::new(stream.try_clone().unwrap());+                let mut reader = TestBufReader::new(stream.try_clone().unwrap());                 loop {                     let mut line = String::new();                     if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {@@ -747,7 +1073,6 @@ pub(crate) mod tests {      #[test]     fn a_complete_body_of_exactly_the_published_size_succeeds() {-        // The bug this guards: a reader limit equal to the size failed on the final read.         let (result, _) = run(vec![Reply::Full]);         assert_eq!(result.unwrap(), body().0);     }@@ -782,7 +1107,6 @@ pub(crate) mod tests {         let dest = dir.path().join("file.bin");         let http = Http::new().unwrap();         let stop = Stop::default();-        // Pause as soon as the first bytes arrive.         let err = http             .download(&url, &dest, len, &sha, &stop, &mut |t| {                 if matches!(t, Transfer::Bytes { done, .. } if done > 0) {@@ -856,22 +1180,18 @@ pub(crate) mod tests {     fn an_oversized_partial_file_that_cannot_be_removed_reports_an_error() {         let dir = tempfile::tempdir().unwrap();         let part = part_path(&dir.path().join("file.bin"), 1);-        // A directory cannot be removed as a file on Unix. On Windows, hold a file open-        // without delete sharing: metadata remains readable, but removing it is refused.         #[cfg(unix)]         std::fs::create_dir(&part).unwrap();         #[cfg(windows)]         let _locked_partial = {             use std::os::windows::fs::OpenOptionsExt;             std::fs::write(&part, b"too large").unwrap();-            // FILE_SHARE_READ | FILE_SHARE_WRITE, without FILE_SHARE_DELETE.             std::fs::File::options().read(true).share_mode(0x00000003).open(&part).unwrap()         };         assert!(std::fs::metadata(&part).unwrap().len() > 1);         assert!(std::fs::remove_file(&part).is_err());         let http = Http::new().unwrap();         let stop = Stop::default();-        // Bound the regression: the old retry loop spins until Cancel, without a request.         let (finished, done) = std::sync::mpsc::channel();         let error = std::thread::scope(|scope| {             let watchdog_stop = &stop;@@ -888,7 +1208,6 @@ pub(crate) mod tests {         assert!(part.exists(), "a blocked partial file must be left alone");     } -    /// Live: a real Craft App release asset (`cargo test -p artcraft-launcher-native -- --ignored`).     #[test]     #[ignore = "needs network access"]     fn downloads_a_real_release_asset() {@@ -907,13 +1226,12 @@ pub(crate) mod tests {         assert_eq!(std::fs::metadata(&dest).unwrap().len(), asset.size);     } -    /// Answers one request with `head` (status line and headers) and an empty body.     fn answer_once(head: String) -> String {         let listener = TcpListener::bind("127.0.0.1:0").unwrap();         let url = format!("http://{}/repos/storytold/photocraft/releases/latest", listener.local_addr().unwrap());         std::thread::spawn(move || {             let Ok((mut stream, _)) = listener.accept() else { return };-            let mut reader = BufReader::new(stream.try_clone().unwrap());+            let mut reader = TestBufReader::new(stream.try_clone().unwrap());             loop {                 let mut line = String::new();                 if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {@@ -927,8 +1245,6 @@ pub(crate) mod tests {      #[test]     fn a_secondary_limit_waits_for_retry_after_not_the_hourly_reset() {-        // GitHub's secondary limit: quota left, an hourly reset 50 minutes away, and "try again-        // in a minute". That's a minute, not 50 minutes of "the hourly limit was reached".         let now = crate::now();         let url = answer_once(format!(             "HTTP/1.1 403 Forbidden\r\nx-ratelimit-remaining: 41\r\nx-ratelimit-reset: {}\r\nretry-after: 60\r\n",@@ -949,15 +1265,11 @@ pub(crate) mod tests {     #[test]     fn github_refusals_are_read_the_way_github_documents_them() {         const NOW: i64 = 1_791_500_000;-        // Out of quota: until the reset, or Retry-After if that's later.         assert_eq!(refused_until(403, Some("0"), Some(NOW + 1800), None, NOW), Some(Some(NOW + 1800)));         assert_eq!(refused_until(403, Some("0"), Some(NOW + 30), Some(90), NOW), Some(Some(NOW + 90)));         assert_eq!(refused_until(403, Some("0"), None, None, NOW), Some(None));-        // A secondary limit: Retry-After, never the hourly reset.         assert_eq!(refused_until(429, Some("12"), Some(NOW + 1800), Some(30), NOW), Some(Some(NOW + 30)));-        // A 429 that doesn't say: a minute.         assert_eq!(refused_until(429, Some("12"), Some(NOW + 1800), None, NOW), Some(Some(NOW + 60)));-        // A plain 403 (blocked, private, …) isn't a rate limit.         assert_eq!(refused_until(403, Some("12"), Some(NOW + 1800), None, NOW), None);         assert_eq!(refused_until(403, None, None, None, NOW), None);     }@@ -966,4 +1278,4 @@ pub(crate) mod tests {     fn partial_files_are_named_after_the_file_and_size() {         assert_eq!(part_path(Path::new("/d/photocraft.dmg"), 9), Path::new("/d/photocraft.dmg.9.part"));     }-}+}\ No newline at end of file