refactor: better handling of unstable connection during asset downloads

This commit is contained in:
zhom committed 2026-07-08 01:19:52 +04:00
1 parent 95189c7c6c
commit eb6ded2772
1 file changed
+254 -202
+254 -202
View File
@@ -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