mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-13 19:20:48 +02:00
refactor: better handling of unstable connection during asset downloads
This commit is contained in:
1 parent
95189c7c6c
commit
eb6ded2772
1 file changed
+254
-202
+254
-202
@@ -327,238 +327,290 @@ impl Downloader {
|
|||||||
.await?;
|
.await?;
|
||||||
log::info!("Download URL resolved: {}", download_url);
|
log::info!("Download URL resolved: {}", download_url);
|
||||||
|
|
||||||
// Determine if we have a partial file to resume
|
// In-session resume: a large (~1GB) download over a flaky connection can
|
||||||
let mut existing_size: u64 = 0;
|
// drop mid-stream. Rather than surfacing the first stall/chunk error as a
|
||||||
if let Ok(meta) = std::fs::metadata(&file_path) {
|
// terminal failure (which forces the user to re-click and risks the CDN
|
||||||
existing_size = meta.len();
|
// answering 200 = full restart), re-issue a ranged GET and keep appending to
|
||||||
}
|
// the same partial file. `existing_size` is re-read from disk each pass so
|
||||||
|
// the Range offset always matches the bytes already flushed.
|
||||||
|
let max_send_retries = 5u32;
|
||||||
|
let max_stream_restarts = 5u32;
|
||||||
|
let mut stream_restarts = 0u32;
|
||||||
|
let start_time = std::time::Instant::now();
|
||||||
|
|
||||||
// Build request with retry logic for transient network errors.
|
use futures_util::StreamExt;
|
||||||
let max_retries = 3u32;
|
use std::fs::OpenOptions;
|
||||||
let mut response: Option<reqwest::Response> = None;
|
use std::io::Write;
|
||||||
for attempt in 0..=max_retries {
|
|
||||||
let mut request = self
|
|
||||||
.client
|
|
||||||
.get(&download_url)
|
|
||||||
.header(
|
|
||||||
"User-Agent",
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
|
||||||
);
|
|
||||||
|
|
||||||
if existing_size > 0 {
|
loop {
|
||||||
request = request.header("Range", format!("bytes={existing_size}-"));
|
// Determine how much of a partial file we already have on disk.
|
||||||
}
|
let mut existing_size: u64 = std::fs::metadata(&file_path).map(|m| m.len()).unwrap_or(0);
|
||||||
|
|
||||||
log::info!("Sending download request (attempt {})...", attempt + 1);
|
// Build request with retry logic for transient connect/timeout errors.
|
||||||
match request.send().await {
|
let mut response: Option<reqwest::Response> = None;
|
||||||
Ok(resp) => {
|
for attempt in 0..=max_send_retries {
|
||||||
log::info!(
|
let mut request = self
|
||||||
"Download response received: status={}, content-length={:?}",
|
.client
|
||||||
resp.status(),
|
.get(&download_url)
|
||||||
resp.content_length()
|
.header(
|
||||||
|
"User-Agent",
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||||
);
|
);
|
||||||
if resp.status().as_u16() == 416 && existing_size > 0 {
|
|
||||||
let _ = std::fs::remove_file(&file_path);
|
if existing_size > 0 {
|
||||||
existing_size = 0;
|
request = request.header("Range", format!("bytes={existing_size}-"));
|
||||||
log::warn!("Download returned 416, retrying without Range header");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
response = Some(resp);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
let is_retryable = e.is_connect() || e.is_timeout() || e.is_request();
|
log::info!("Sending download request (attempt {})...", attempt + 1);
|
||||||
if is_retryable && attempt < max_retries {
|
match request.send().await {
|
||||||
let delay = 2u64.pow(attempt);
|
Ok(resp) => {
|
||||||
log::warn!(
|
log::info!(
|
||||||
"Download attempt {} failed ({}), retrying in {}s...",
|
"Download response received: status={}, content-length={:?}",
|
||||||
attempt + 1,
|
resp.status(),
|
||||||
e,
|
resp.content_length()
|
||||||
delay
|
|
||||||
);
|
);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
if resp.status().as_u16() == 416 && existing_size > 0 {
|
||||||
} else {
|
// The requested range is past the end of the object. Parse
|
||||||
return Err(format!("Download failed after {} attempts: {}", attempt + 1, e).into());
|
// `Content-Range: bytes */total`: if the partial already covers the
|
||||||
}
|
// whole object it is complete (keep it); otherwise it is corrupt or
|
||||||
}
|
// oversized, so discard and restart from scratch.
|
||||||
}
|
let server_total = resp
|
||||||
}
|
.headers()
|
||||||
let response = response.ok_or_else(|| -> Box<dyn std::error::Error + Send + Sync> {
|
.get(reqwest::header::CONTENT_RANGE)
|
||||||
"Download failed: no response received".into()
|
.and_then(|v| v.to_str().ok())
|
||||||
})?;
|
.and_then(|s| s.rsplit('/').next())
|
||||||
|
.and_then(|t| t.trim().parse::<u64>().ok());
|
||||||
// Check if the response is successful (200 OK or 206 Partial Content)
|
let partial_is_complete = match server_total {
|
||||||
if !(response.status().is_success() || response.status().as_u16() == 206) {
|
Some(total) => existing_size >= total,
|
||||||
return Err(format!("Download failed with status: {}", response.status()).into());
|
None => true,
|
||||||
}
|
};
|
||||||
|
if partial_is_complete {
|
||||||
// Determine total size
|
log::info!(
|
||||||
let mut total_size = response.content_length();
|
"Archive {} already complete ({} bytes), skipping download",
|
||||||
|
file_path.display(),
|
||||||
// If resuming (206) and Content-Range is present, parse total
|
existing_size
|
||||||
if response.status().as_u16() == 206 {
|
);
|
||||||
if let Some(content_range) = response.headers().get(reqwest::header::CONTENT_RANGE) {
|
return Ok(file_path);
|
||||||
if let Ok(cr) = content_range.to_str() {
|
|
||||||
// Format: bytes start-end/total
|
|
||||||
if let Some((_, total_str)) = cr.split('/').collect::<Vec<_>>().split_first() {
|
|
||||||
if let Some(total_str) = total_str.first() {
|
|
||||||
if let Ok(total) = total_str.parse::<u64>() {
|
|
||||||
total_size = Some(total);
|
|
||||||
}
|
}
|
||||||
|
let _ = std::fs::remove_file(&file_path);
|
||||||
|
existing_size = 0;
|
||||||
|
log::warn!("Download returned 416 with an incomplete partial, restarting from 0");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
response = Some(resp);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let is_retryable = e.is_connect() || e.is_timeout() || e.is_request();
|
||||||
|
if is_retryable && attempt < max_send_retries {
|
||||||
|
let delay = 2u64.pow(attempt.min(4));
|
||||||
|
log::warn!(
|
||||||
|
"Download attempt {} failed ({}), retrying in {}s...",
|
||||||
|
attempt + 1,
|
||||||
|
e,
|
||||||
|
delay
|
||||||
|
);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||||
|
} else {
|
||||||
|
return Err(format!("Download failed after {} attempts: {}", attempt + 1, e).into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if let Some(len) = response.headers().get(reqwest::header::CONTENT_LENGTH) {
|
}
|
||||||
// Fallback: total = existing + incoming length
|
let response = response.ok_or_else(|| -> Box<dyn std::error::Error + Send + Sync> {
|
||||||
if let Ok(len_str) = len.to_str() {
|
"Download failed: no response received".into()
|
||||||
if let Ok(incoming) = len_str.parse::<u64>() {
|
})?;
|
||||||
total_size = Some(existing_size + incoming);
|
|
||||||
|
// Check if the response is successful (200 OK or 206 Partial Content)
|
||||||
|
if !(response.status().is_success() || response.status().as_u16() == 206) {
|
||||||
|
return Err(format!("Download failed with status: {}", response.status()).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine total size
|
||||||
|
let mut total_size = response.content_length();
|
||||||
|
|
||||||
|
// If resuming (206) and Content-Range is present, parse total
|
||||||
|
if response.status().as_u16() == 206 {
|
||||||
|
if let Some(content_range) = response.headers().get(reqwest::header::CONTENT_RANGE) {
|
||||||
|
if let Ok(cr) = content_range.to_str() {
|
||||||
|
// Format: bytes start-end/total
|
||||||
|
if let Some(total) = cr
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.and_then(|t| t.trim().parse::<u64>().ok())
|
||||||
|
{
|
||||||
|
total_size = Some(total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(len) = response.headers().get(reqwest::header::CONTENT_LENGTH) {
|
||||||
|
// Fallback: total = existing + incoming length
|
||||||
|
if let Ok(len_str) = len.to_str() {
|
||||||
|
if let Ok(incoming) = len_str.parse::<u64>() {
|
||||||
|
total_size = Some(existing_size + incoming);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if existing_size > 0 && response.status().is_success() {
|
||||||
|
// Server ignored range or we asked from 0; if 200 and existing file has content, start fresh
|
||||||
|
// Truncate existing file so we don't append duplicate bytes
|
||||||
|
let _ = std::fs::remove_file(&file_path);
|
||||||
|
existing_size = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the existing file already matches the total size, skip the download
|
||||||
|
if existing_size > 0 {
|
||||||
|
if let Some(total) = total_size {
|
||||||
|
if existing_size >= total {
|
||||||
|
log::info!(
|
||||||
|
"Archive {} already complete ({} bytes), skipping download",
|
||||||
|
file_path.display(),
|
||||||
|
existing_size
|
||||||
|
);
|
||||||
|
return Ok(file_path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if existing_size > 0 && response.status().is_success() {
|
|
||||||
// Server ignored range or we asked from 0; if 200 and existing file has content, start fresh
|
|
||||||
// Truncate existing file so we don't append duplicate bytes
|
|
||||||
let _ = std::fs::remove_file(&file_path);
|
|
||||||
existing_size = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the existing file already matches the total size, skip the download
|
let mut downloaded = existing_size;
|
||||||
if existing_size > 0 {
|
let mut last_update = std::time::Instant::now();
|
||||||
if let Some(total) = total_size {
|
|
||||||
if existing_size >= total {
|
|
||||||
log::info!(
|
|
||||||
"Archive {} already complete ({} bytes), skipping download",
|
|
||||||
file_path.display(),
|
|
||||||
existing_size
|
|
||||||
);
|
|
||||||
return Ok(file_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut downloaded = existing_size;
|
// Emit initial progress AFTER we've established total size and resume state
|
||||||
let start_time = std::time::Instant::now();
|
let initial_percentage = match total_size {
|
||||||
let mut last_update = start_time;
|
Some(total) if total > 0 => (existing_size as f64 / total as f64) * 100.0,
|
||||||
|
_ => 0.0,
|
||||||
|
};
|
||||||
|
let _ = events::emit(
|
||||||
|
"download-progress",
|
||||||
|
&DownloadProgress {
|
||||||
|
browser: browser_type.as_str().to_string(),
|
||||||
|
version: version.to_string(),
|
||||||
|
downloaded_bytes: existing_size,
|
||||||
|
total_bytes: total_size,
|
||||||
|
percentage: initial_percentage,
|
||||||
|
speed_bytes_per_sec: 0.0,
|
||||||
|
eta_seconds: None,
|
||||||
|
stage: "downloading".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Emit initial progress AFTER we've established total size and resume state
|
// Open file in append mode (resuming) or create new.
|
||||||
let initial_percentage = if let Some(total) = total_size {
|
// Wrap in BufWriter with a large buffer to reduce the number of disk writes,
|
||||||
if total > 0 {
|
// which dramatically improves download speed on Windows (NTFS + Defender overhead).
|
||||||
(existing_size as f64 / total as f64) * 100.0
|
let raw_file = OpenOptions::new()
|
||||||
} else {
|
.create(true)
|
||||||
0.0
|
.append(true)
|
||||||
}
|
.open(&file_path)?;
|
||||||
} else {
|
let mut file = io::BufWriter::with_capacity(8 * 1024 * 1024, raw_file);
|
||||||
0.0
|
let mut stream = response.bytes_stream();
|
||||||
};
|
|
||||||
|
|
||||||
let initial_stage = "downloading".to_string();
|
// On a mid-stream failure (idle stall or chunk error) we record it here and,
|
||||||
|
// after flushing what we have, decide whether to resume or give up.
|
||||||
|
let mut retryable_stream_err: Option<String> = None;
|
||||||
|
|
||||||
let progress = DownloadProgress {
|
loop {
|
||||||
browser: browser_type.as_str().to_string(),
|
// Wrap each read in an idle timeout so a stalled connection (no bytes flowing)
|
||||||
version: version.to_string(),
|
// surfaces as a retryable error instead of awaiting forever.
|
||||||
downloaded_bytes: existing_size,
|
let next = match tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()).await {
|
||||||
total_bytes: total_size,
|
Ok(item) => item,
|
||||||
percentage: initial_percentage,
|
Err(_) => {
|
||||||
speed_bytes_per_sec: 0.0,
|
retryable_stream_err = Some(format!(
|
||||||
eta_seconds: None,
|
|
||||||
stage: initial_stage,
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = events::emit("download-progress", &progress);
|
|
||||||
|
|
||||||
// Open file in append mode (resuming) or create new.
|
|
||||||
// Wrap in BufWriter with a large buffer to reduce the number of disk writes,
|
|
||||||
// which dramatically improves download speed on Windows (NTFS + Defender overhead).
|
|
||||||
use std::fs::OpenOptions;
|
|
||||||
use std::io::Write;
|
|
||||||
let raw_file = OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.append(true)
|
|
||||||
.open(&file_path)?;
|
|
||||||
let mut file = io::BufWriter::with_capacity(8 * 1024 * 1024, raw_file);
|
|
||||||
let mut stream = response.bytes_stream();
|
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
loop {
|
|
||||||
// Wrap each read in an idle timeout so a stalled connection (no bytes flowing)
|
|
||||||
// surfaces as a terminal error instead of awaiting forever.
|
|
||||||
let next = match tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()).await {
|
|
||||||
Ok(item) => item,
|
|
||||||
Err(_) => {
|
|
||||||
drop(file);
|
|
||||||
// Keep any partial bytes on disk so a later attempt can resume via Range.
|
|
||||||
return Err(
|
|
||||||
format!(
|
|
||||||
"Download stalled: no data received for {}s",
|
"Download stalled: no data received for {}s",
|
||||||
STREAM_IDLE_TIMEOUT.as_secs()
|
STREAM_IDLE_TIMEOUT.as_secs()
|
||||||
)
|
));
|
||||||
.into(),
|
break;
|
||||||
);
|
}
|
||||||
|
};
|
||||||
|
let Some(chunk) = next else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if let Some(token) = cancel_token {
|
||||||
|
if token.is_cancelled() {
|
||||||
|
let _ = file.flush();
|
||||||
|
drop(file);
|
||||||
|
let _ = std::fs::remove_file(&file_path);
|
||||||
|
return Err("Download cancelled".into());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
let chunk = match chunk {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
retryable_stream_err = Some(format!("Download chunk error: {e}"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
file.write_all(&chunk)?;
|
||||||
|
downloaded += chunk.len() as u64;
|
||||||
|
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
// Update progress every 100ms to avoid too many events
|
||||||
|
if now.duration_since(last_update).as_millis() >= 100 {
|
||||||
|
let elapsed = start_time.elapsed().as_secs_f64();
|
||||||
|
// Compute speed based only on bytes downloaded in this session to avoid inflated values when resuming
|
||||||
|
let downloaded_since_start = downloaded.saturating_sub(existing_size);
|
||||||
|
let speed = if elapsed > 0.0 {
|
||||||
|
downloaded_since_start as f64 / elapsed
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let percentage = match total_size {
|
||||||
|
Some(total) if total > 0 => (downloaded as f64 / total as f64) * 100.0,
|
||||||
|
_ => 0.0,
|
||||||
|
};
|
||||||
|
let eta = if speed > 0.0 {
|
||||||
|
total_size.map(|total| total.saturating_sub(downloaded) as f64 / speed)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = events::emit(
|
||||||
|
"download-progress",
|
||||||
|
&DownloadProgress {
|
||||||
|
browser: browser_type.as_str().to_string(),
|
||||||
|
version: version.to_string(),
|
||||||
|
downloaded_bytes: downloaded,
|
||||||
|
total_bytes: total_size,
|
||||||
|
percentage,
|
||||||
|
speed_bytes_per_sec: speed,
|
||||||
|
eta_seconds: eta,
|
||||||
|
stage: "downloading".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
last_update = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always flush what we have so a resume (this pass or a later run) starts
|
||||||
|
// from the correct on-disk offset.
|
||||||
|
file.flush()?;
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
let Some(err) = retryable_stream_err else {
|
||||||
|
return Ok(file_path);
|
||||||
};
|
};
|
||||||
let Some(chunk) = next else {
|
|
||||||
break;
|
// Re-check cancellation before scheduling a retry.
|
||||||
};
|
|
||||||
if let Some(token) = cancel_token {
|
if let Some(token) = cancel_token {
|
||||||
if token.is_cancelled() {
|
if token.is_cancelled() {
|
||||||
drop(file);
|
|
||||||
let _ = std::fs::remove_file(&file_path);
|
let _ = std::fs::remove_file(&file_path);
|
||||||
return Err("Download cancelled".into());
|
return Err("Download cancelled".into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let chunk = chunk?;
|
if stream_restarts >= max_stream_restarts {
|
||||||
file.write_all(&chunk)?;
|
// Keep the partial on disk so a later run (or app restart) can resume.
|
||||||
downloaded += chunk.len() as u64;
|
return Err(err.into());
|
||||||
|
|
||||||
let now = std::time::Instant::now();
|
|
||||||
// Update progress every 100ms to avoid too many events
|
|
||||||
if now.duration_since(last_update).as_millis() >= 100 {
|
|
||||||
let elapsed = start_time.elapsed().as_secs_f64();
|
|
||||||
// Compute speed based only on bytes downloaded in this session to avoid inflated values when resuming
|
|
||||||
let downloaded_since_start = downloaded.saturating_sub(existing_size);
|
|
||||||
let speed = if elapsed > 0.0 {
|
|
||||||
downloaded_since_start as f64 / elapsed
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
let percentage = if let Some(total) = total_size {
|
|
||||||
if total > 0 {
|
|
||||||
(downloaded as f64 / total as f64) * 100.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
let eta = if speed > 0.0 {
|
|
||||||
total_size.map(|total| (total - downloaded) as f64 / speed)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let stage_description = "downloading".to_string();
|
|
||||||
|
|
||||||
let progress = DownloadProgress {
|
|
||||||
browser: browser_type.as_str().to_string(),
|
|
||||||
version: version.to_string(),
|
|
||||||
downloaded_bytes: downloaded,
|
|
||||||
total_bytes: total_size,
|
|
||||||
percentage,
|
|
||||||
speed_bytes_per_sec: speed,
|
|
||||||
eta_seconds: eta,
|
|
||||||
stage: stage_description,
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = events::emit("download-progress", &progress);
|
|
||||||
last_update = now;
|
|
||||||
}
|
}
|
||||||
|
stream_restarts += 1;
|
||||||
|
let delay = 2u64.pow(stream_restarts.min(4));
|
||||||
|
log::warn!(
|
||||||
|
"{} — resuming from {} bytes (restart {}/{}) in {}s",
|
||||||
|
err,
|
||||||
|
std::fs::metadata(&file_path).map(|m| m.len()).unwrap_or(0),
|
||||||
|
stream_restarts,
|
||||||
|
max_stream_restarts,
|
||||||
|
delay
|
||||||
|
);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush remaining buffered data to disk
|
|
||||||
file.flush()?;
|
|
||||||
|
|
||||||
Ok(file_path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download a browser binary, verify it, and register it in the downloaded browsers registry
|
/// Download a browser binary, verify it, and register it in the downloaded browsers registry
|
||||||
|
|||||||
Reference in new issue
Block a user