Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Unreleased

- Resume interrupted downloads via HTTP Range headers. When a download fails due to a network error,
elan now automatically retries once, resuming from where it left off. Also adds a stall timeout
(30s at <10 bytes/sec) so downloads no longer hang indefinitely on unreliable connections.
- Support absolute and relative toolchain paths in `lean-toolchain` etc.

# 4.1.2 - 2025-05-26
Expand Down
3 changes: 3 additions & 0 deletions src/download/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,8 @@ error_chain! {
description("download backend unavailable")
display("download backend '{}' unavailable", be)
}
ResumeNotSupported {
description("server does not support resuming downloads")
}
}
}
111 changes: 91 additions & 20 deletions src/download/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ pub enum Backend {

#[derive(Debug, Copy, Clone)]
pub enum Event<'a> {
/// Resuming a partial download.
ResumingPartialDownload,
/// Received the Content-Length of the to-be downloaded data.
DownloadContentLengthReceived(u64),
/// Received some data.
Expand All @@ -23,33 +25,68 @@ pub enum Event<'a> {
fn download_with_backend(
backend: Backend,
url: &Url,
resume_from: u64,
callback: &dyn Fn(Event<'_>) -> Result<()>,
) -> Result<()> {
match backend {
Backend::Curl => curl::download(url, callback),
Backend::Curl => curl::download(url, resume_from, callback),
}
}

pub fn download_to_path_with_backend(
backend: Backend,
url: &Url,
path: &Path,
resume_from_partial: bool,
callback: Option<&dyn Fn(Event<'_>) -> Result<()>>,
) -> Result<()> {
use std::cell::RefCell;
use std::fs::OpenOptions;
use std::io::Write;
use std::io::{Seek, SeekFrom, Write};

|| -> Result<()> {
let file = OpenOptions::new()
.write(true)
.create(true)
.open(path)
.chain_err(|| "error creating file for download")?;
let (file, resume_from) = if resume_from_partial {
let possible_partial = OpenOptions::new().read(true).open(path);

let downloaded_so_far = if let Ok(partial) = possible_partial {
if let Some(cb) = callback {
cb(Event::ResumingPartialDownload)?;
}
let file_info = partial
.metadata()
.chain_err(|| "error reading partial download metadata")?;
file_info.len()
} else {
0
};

let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(downloaded_so_far == 0)
.open(path)
.chain_err(|| "error opening file for download")?;

if downloaded_so_far > 0 {
file.seek(SeekFrom::End(0))
.chain_err(|| "error seeking in partial download")?;
}

(file, downloaded_so_far)
} else {
let file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)
.chain_err(|| "error creating file for download")?;

(file, 0)
};

let file = RefCell::new(file);

download_with_backend(backend, url, &|event| {
download_with_backend(backend, url, resume_from, &|event| {
if let Event::DownloadDataReceived(data) = event {
file.borrow_mut()
.write_all(data)
Expand Down Expand Up @@ -84,7 +121,11 @@ pub mod curl {

thread_local!(pub static EASY: RefCell<Easy> = RefCell::new(Easy::new()));

pub fn download(url: &Url, callback: &dyn Fn(Event<'_>) -> Result<()>) -> Result<()> {
pub fn download(
url: &Url,
resume_from: u64,
callback: &dyn Fn(Event<'_>) -> Result<()>,
) -> Result<()> {
// Fetch either a cached libcurl handle (which will preserve open
// connections) or create a new one if it isn't listed.
//
Expand All @@ -98,11 +139,30 @@ pub mod curl {
.follow_location(true)
.chain_err(|| "failed to set follow redirects")?;

if resume_from > 0 {
handle
.resume_from(resume_from)
.chain_err(|| "failed to set resume from")?;
} else {
// An error here indicates that the range header isn't supported
// by underlying curl, so there's nothing to "clear" — safe to
// ignore this error.
let _ = handle.resume_from(0);
}

// Take at most 30s to connect
handle
.connect_timeout(Duration::new(30, 0))
.chain_err(|| "failed to set connect timeout")?;

// Abort if the speed is below 10 bytes/sec for 30 seconds
handle
.low_speed_limit(10)
.chain_err(|| "failed to set low speed limit")?;
handle
.low_speed_time(Duration::new(30, 0))
.chain_err(|| "failed to set low speed time")?;

{
let cberr = RefCell::new(None);
let mut transfer = handle.transfer();
Expand All @@ -120,20 +180,24 @@ pub mod curl {
})
.chain_err(|| "failed to set write")?;

// Listen for headers and parse out a `Content-Length` if it comes
// so we know how much we're downloading.
// Listen for headers and parse out a `Content-Length`
// (case-insensitive) if it comes so we know how much we're
// downloading.
transfer
.header_function(|header| {
if let Ok(data) = str::from_utf8(header) {
let prefix = "Content-Length: ";
if data.starts_with(prefix) {
if let Ok(s) = data[prefix.len()..].trim().parse::<u64>() {
let msg = Event::DownloadContentLengthReceived(s);
match callback(msg) {
Ok(()) => (),
Err(e) => {
*cberr.borrow_mut() = Some(e);
return false;
let prefix = "content-length: ";
if let Some((dp, ds)) = data.split_at_checked(prefix.len()) {
if dp.eq_ignore_ascii_case(prefix) {
if let Ok(s) = ds.trim().parse::<u64>() {
let msg =
Event::DownloadContentLengthReceived(s + resume_from);
match callback(msg) {
Ok(()) => (),
Err(e) => {
*cberr.borrow_mut() = Some(e);
return false;
}
}
}
}
Expand Down Expand Up @@ -173,6 +237,13 @@ pub mod curl {
}
};

// If we asked for a range but got 200 instead of 206, the server
// ignored our Range header and sent the full file. Report this so
// the caller can truncate and use the full response.
if resume_from > 0 && code == 200 {
return Err(ErrorKind::ResumeNotSupported.into());
}

Ok(())
})
}
Expand Down
13 changes: 13 additions & 0 deletions src/elan-cli/download_tracker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ impl DownloadTracker {
self.download_finished();
true
}
Notification::Install(In::Utils(Un::ResumingPartialDownload)) => {
self.resuming_partial_download();
true
}
_ => false,
}
}
Expand Down Expand Up @@ -104,6 +108,15 @@ impl DownloadTracker {
}
}
}
/// Notifies self that a partial download is being resumed.
/// Resets speed tracking but preserves total_downloaded since the
/// content-length from the server will reflect the full file size.
pub fn resuming_partial_download(&mut self) {
self.downloaded_this_sec = 0;
self.downloaded_last_few_secs.clear();
self.seconds_elapsed = 0;
self.last_sec = None;
}
/// Notifies self that the download has finished.
pub fn download_finished(&mut self) {
if self.displayed_charcount.is_some() {
Expand Down
60 changes: 54 additions & 6 deletions src/elan-utils/src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,17 +126,58 @@ pub fn download_file(
url: &Url,
path: &Path,
notify_handler: &dyn Fn(Notification<'_>),
) -> Result<()> {
download_file_with_resume(url, path, false, notify_handler)
}

pub fn download_file_with_resume(
url: &Url,
path: &Path,
resume_from_partial: bool,
notify_handler: &dyn Fn(Notification<'_>),
) -> Result<()> {
use download::ErrorKind as DEK;
match download_file_(url, path, notify_handler) {
match download_file_(url, path, resume_from_partial, notify_handler) {
Ok(_) => Ok(()),
Err(e) => {
println!("{:?}", e);
let is_client_error = match e.kind() {
&ErrorKind::Download(DEK::HttpStatus(400..=499)) => true,
&ErrorKind::Download(DEK::FileNotFound) => true,
_ => false,
};

// On 416 (Range Not Satisfiable) or ResumeNotSupported (server
// returned 200 instead of 206), fall back to a fresh download.
let should_restart_fresh = matches!(
e.kind(),
&ErrorKind::Download(DEK::HttpStatus(416))
| &ErrorKind::Download(DEK::ResumeNotSupported)
);
if should_restart_fresh {
match download_file_(url, path, false, notify_handler) {
Ok(_) => return Ok(()),
Err(retry_err) => {
return Err(retry_err).chain_err(|| ErrorKind::DownloadingFile {
url: url.clone(),
path: path.to_path_buf(),
});
}
}
}

// On network errors, retry once with resume from the partial file.
if !is_client_error && !resume_from_partial {
match download_file_(url, path, true, notify_handler) {
Ok(_) => return Ok(()),
Err(retry_err) => {
return Err(retry_err).chain_err(|| ErrorKind::DownloadingFile {
url: url.clone(),
path: path.to_path_buf(),
});
}
}
}

Err(e).chain_err(|| {
if is_client_error {
ErrorKind::DownloadNotExists {
Expand All @@ -154,16 +195,23 @@ pub fn download_file(
}
}

fn download_file_(url: &Url, path: &Path, notify_handler: &dyn Fn(Notification<'_>)) -> Result<()> {
fn download_file_(
url: &Url,
path: &Path,
resume_from_partial: bool,
notify_handler: &dyn Fn(Notification<'_>),
) -> Result<()> {
use download::download_to_path_with_backend;
use download::{Backend, Event};

notify_handler(Notification::DownloadingFile(url, path));

// This callback will write the download to disk and optionally
// hash the contents, then forward the notification up the stack
// This callback will forward the notification up the stack
let callback: &dyn Fn(Event<'_>) -> download::Result<()> = &|msg| {
match msg {
Event::ResumingPartialDownload => {
notify_handler(Notification::ResumingPartialDownload);
}
Event::DownloadContentLengthReceived(len) => {
notify_handler(Notification::DownloadContentLengthReceived(len));
}
Expand All @@ -179,7 +227,7 @@ fn download_file_(url: &Url, path: &Path, notify_handler: &dyn Fn(Notification<'

let (backend, notification) = (Backend::Curl, Notification::UsingCurl);
notify_handler(notification);
download_to_path_with_backend(backend, url, path, Some(callback))?;
download_to_path_with_backend(backend, url, path, resume_from_partial, Some(callback))?;

notify_handler(Notification::DownloadFinished);

Expand Down