Files
Starlight_Lancher/packages/app-lib/src/util/download/h2_range.rs
T
Disy 91e2c369f5
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
fix: 优化下载并发调度,发布 1.0.2-beta4
2026-10-08 12:10:54 +08:00

576 lines
20 KiB
Rust

//! Multi-range download of one file over a shared HTTP/2 connection.
use super::h2_download::{H2DownloadFailure, H2DownloadOutcome};
use super::h2_pool::SharedH2Connection;
use crate::util::fetch::{
self, DownloadRequest, DownloadResult, DownloadRoute,
};
use futures::stream::{FuturesUnordered, StreamExt};
use http::header::{ACCEPT_ENCODING, RANGE};
use http::{HeaderValue, StatusCode, Uri};
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
struct H2Range {
start: u64,
end: u64,
}
struct PartCleanupGuard {
path: PathBuf,
armed: bool,
}
impl PartCleanupGuard {
fn new(path: &Path) -> Self {
Self {
path: path.to_path_buf(),
armed: true,
}
}
fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for PartCleanupGuard {
fn drop(&mut self) {
if self.armed {
let _ = std::fs::remove_file(&self.path);
}
}
}
pub(crate) async fn download(
connection: &Arc<SharedH2Connection>,
uri: &Uri,
request: &DownloadRequest,
route: &DownloadRoute,
destination: &Path,
part_path: &Path,
total_size: u64,
concurrency: usize,
mut progress: Option<&mut fetch::FetchProgressFn<'_>>,
) -> H2DownloadOutcome {
if request
.cancellation
.as_ref()
.is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
{
return H2DownloadOutcome::Canceled;
}
if total_size == 0 {
return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Content,
preserve_partial: false,
};
}
let count = concurrency
.max(1)
.min(usize::try_from(total_size).unwrap_or(usize::MAX).max(1));
let mut cleanup = PartCleanupGuard::new(part_path);
let output =
match super::range_output::RangeOutput::create(part_path, total_size)
.await
{
Ok(output) => output,
Err(_) => {
return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Io,
preserve_partial: false,
};
}
};
let ranges = split_ranges(total_size, count);
let downloaded = Arc::new(AtomicU64::new(0));
let reported_bucket = Arc::new(AtomicU64::new(0));
let progress_delta = (total_size / 200).max(256 * 1024);
let mut tasks = FuturesUnordered::new();
for range in ranges {
tasks.push(download_range(
Arc::clone(connection),
uri.clone(),
request,
route,
Arc::clone(&output),
range,
total_size,
Arc::clone(&downloaded),
Arc::clone(&reported_bucket),
progress_delta,
));
}
let mut progress_interval =
tokio::time::interval(Duration::from_millis(200));
progress_interval
.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let cancellation = request.cancellation.clone().unwrap_or_default();
loop {
let next = tokio::select! {
biased;
_ = cancellation.cancelled() => {
drop(tasks);
drop(output);
return H2DownloadOutcome::Canceled;
}
_ = progress_interval.tick(), if progress.is_some() => {
if let Some(callback) = progress.as_deref_mut()
&& callback(downloaded.load(Ordering::Relaxed).min(total_size), total_size).await.is_err()
{
drop(tasks);
drop(output);
return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Io,
preserve_partial: false,
};
}
continue;
}
result = tasks.next() => result,
};
let Some(result) = next else {
break;
};
if let Err(failure) = result {
drop(tasks);
drop(output);
return H2DownloadOutcome::Fallback {
failure,
preserve_partial: false,
};
}
}
drop(output);
if let Some(callback) = progress.as_deref_mut()
&& callback(total_size, total_size).await.is_err()
{
return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Io,
preserve_partial: false,
};
}
let verification = if let Some(cancellation) = request.cancellation.as_ref()
{
tokio::select! {
biased;
_ = cancellation.cancelled() => return H2DownloadOutcome::Canceled,
result = fetch::verify_file(part_path, &request.integrity) => result,
}
} else {
fetch::verify_file(part_path, &request.integrity).await
};
if let Err(error) = verification {
let failure = if fetch::is_integrity_error(&error) {
H2DownloadFailure::Integrity
} else {
H2DownloadFailure::Content
};
return H2DownloadOutcome::Fallback {
failure,
preserve_partial: false,
};
}
let finalized = if let Some(cancellation) = request.cancellation.as_ref() {
tokio::select! {
biased;
_ = cancellation.cancelled() => return H2DownloadOutcome::Canceled,
result = fetch::finalize_download(part_path, destination) => result,
}
} else {
fetch::finalize_download(part_path, destination).await
};
if finalized.is_err() {
return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Io,
preserve_partial: false,
};
}
cleanup.disarm();
H2DownloadOutcome::Completed(DownloadResult {
path: destination.to_path_buf(),
url: uri.to_string(),
source: route.source,
size: total_size,
attempts: 1,
fallback_count: 0,
})
}
#[allow(clippy::too_many_arguments)]
async fn download_range(
connection: Arc<SharedH2Connection>,
uri: Uri,
request: &DownloadRequest,
route: &DownloadRoute,
output: Arc<super::range_output::RangeOutput>,
range: H2Range,
total_size: u64,
downloaded: Arc<AtomicU64>,
reported_bucket: Arc<AtomicU64>,
progress_delta: u64,
) -> Result<(), H2DownloadFailure> {
super::h2_download::record_install_stage(
request,
crate::install::DownloadItemStatus::WaitingForResource,
)
.await;
let _permit = super::h2_stream_budget::acquire_with_pressure(route)
.await
.map_err(|_| H2DownloadFailure::Connect)?;
super::h2_download::record_install_stage(
request,
crate::install::DownloadItemStatus::Downloading,
)
.await;
let mut writer = output
.open_range(range.start, range.end + 1)
.await
.map_err(|_| H2DownloadFailure::Io)?;
let mut headers = super::h2_download::request_headers(request, route);
headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("identity"));
headers.insert(
RANGE,
HeaderValue::from_str(&format!("bytes={}-{}", range.start, range.end))
.map_err(|_| H2DownloadFailure::Http)?,
);
let (response, mut stream) =
super::h2_download::open_stream(&connection, &uri, headers)
.await
.map_err(|_| H2DownloadFailure::Protocol)?;
if response.status() != StatusCode::PARTIAL_CONTENT
|| !content_range_matches(response.headers(), &range, total_size)
{
return Err(H2DownloadFailure::Http);
}
let activity = super::h2_receive::H2TransferActivity::begin();
let mut offset = range.start;
while let Some(chunk) =
super::h2_receive::receive_chunk(&mut stream, "range")
.await
.map_err(|_| H2DownloadFailure::Protocol)?
{
let remaining = range.end.saturating_add(1).saturating_sub(offset);
if chunk.len() as u64 > remaining {
return Err(H2DownloadFailure::Protocol);
}
let accepted = chunk.len();
writer
.write_next(&chunk[..accepted])
.await
.map_err(|_| H2DownloadFailure::Io)?;
activity.record_bytes(accepted);
super::h2_receive::release_capacity(&mut stream, chunk.len())
.map_err(|_| H2DownloadFailure::Protocol)?;
offset += accepted as u64;
let current = downloaded.fetch_add(accepted as u64, Ordering::Relaxed)
+ accepted as u64;
let bucket = current / progress_delta;
let previous = reported_bucket.fetch_max(bucket, Ordering::Relaxed);
if current >= total_size || bucket > previous {
super::h2_download::record_install_progress(
request,
current.min(total_size),
total_size,
)
.await;
}
}
(offset == range.end + 1)
.then_some(())
.ok_or(H2DownloadFailure::Protocol)?;
writer.flush().await.map_err(|_| H2DownloadFailure::Io)
}
fn split_ranges(size: u64, count: usize) -> Vec<H2Range> {
let base = size / count as u64;
let remainder = size % count as u64;
let mut start = 0;
(0..count)
.map(|index| {
let length = base + u64::from(index < remainder as usize);
let range = H2Range {
start,
end: start + length - 1,
};
start += length;
range
})
.collect()
}
fn content_range_matches(
headers: &http::HeaderMap,
range: &H2Range,
total_size: u64,
) -> bool {
headers
.get(http::header::CONTENT_RANGE)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value
== format!("bytes {}-{}/{}", range.start, range.end, total_size)
})
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
use futures::future::poll_fn;
use http::{Response, StatusCode};
use sha1_smol::Sha1;
#[test]
fn splits_file_into_eight_contiguous_ranges() {
let ranges = split_ranges(83, 8);
assert_eq!(ranges.len(), 8);
assert_eq!(ranges.first().unwrap().start, 0);
assert_eq!(ranges.last().unwrap().end, 82);
assert!(
ranges
.windows(2)
.all(|pair| pair[0].end + 1 == pair[1].start)
);
}
#[test]
fn splits_file_into_sixteen_contiguous_ranges() {
let ranges = split_ranges(1024 * 1024 + 7, 16);
assert_eq!(ranges.len(), 16);
assert_eq!(ranges.first().unwrap().start, 0);
assert_eq!(ranges.last().unwrap().end, 1024 * 1024 + 6);
assert!(
ranges
.windows(2)
.all(|pair| pair[0].end + 1 == pair[1].start)
);
}
#[tokio::test]
async fn downloads_one_file_over_eight_h2_range_streams() {
let data = Arc::new(
(0..2 * 1024 * 1024)
.map(|index| (index % 251) as u8)
.collect::<Vec<_>>(),
);
let request_count = Arc::new(AtomicU64::new(0));
let (client_io, server_io) = tokio::io::duplex(256 * 1024);
let server_data = Arc::clone(&data);
let server_requests = Arc::clone(&request_count);
let server = tokio::spawn(async move {
let mut connection =
h2::server::handshake(server_io).await.unwrap();
while let Some(result) = connection.accept().await {
let (request, mut respond) = result.unwrap();
let data = Arc::clone(&server_data);
let requests = Arc::clone(&server_requests);
tokio::spawn(async move {
requests.fetch_add(1, Ordering::Relaxed);
let value = request.headers()[RANGE].to_str().unwrap();
let value = value.strip_prefix("bytes=").unwrap();
let (start, end) = value.split_once('-').unwrap();
let start = start.parse::<usize>().unwrap();
let end = end.parse::<usize>().unwrap();
let response = Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(
http::header::CONTENT_RANGE,
format!("bytes {start}-{end}/{}", data.len()),
)
.body(())
.unwrap();
let mut stream =
respond.send_response(response, false).unwrap();
let mut offset = start;
while offset <= end {
let wanted = (end - offset + 1).min(16 * 1024);
stream.reserve_capacity(wanted);
let capacity = poll_fn(|cx| stream.poll_capacity(cx))
.await
.unwrap()
.unwrap();
let length = capacity.min(wanted);
let finished = offset + length > end;
stream
.send_data(
Bytes::copy_from_slice(
&data[offset..offset + length],
),
finished,
)
.unwrap();
offset += length;
if offset == start + length {
tokio::time::sleep(Duration::from_millis(300))
.await;
}
}
});
}
});
let mut builder = h2::client::Builder::new();
builder
.initial_window_size(1024 * 1024)
.initial_connection_window_size(64 * 1024 * 1024);
let (sender, mut driver) =
builder.handshake::<_, Bytes>(client_io).await.unwrap();
driver.set_target_window_size(64 * 1024 * 1024);
let client_driver = tokio::spawn(async move { driver.await });
let connection = Arc::new(SharedH2Connection::for_test(sender));
let directory = tempfile::tempdir().unwrap();
let destination = directory.path().join("pack.mrpack");
let part_path = directory.path().join("pack.mrpack.part");
let hash = Sha1::from(&data[..]).hexdigest();
let request = DownloadRequest::new(
"https://h2-range.test/pack.mrpack",
fetch::ResourceClass::Modpack,
)
.with_integrity(
fetch::Integrity::sha1(hash).with_size(data.len() as u64),
);
let route = DownloadRoute {
url: request.url.clone(),
source: fetch::DownloadRouteSource::Official,
is_mirror: false,
allow_sensitive_headers: true,
supports_range: true,
proxy: fetch::ProxyPolicy::Direct,
};
let uri = route.url.parse().unwrap();
let mut reports = Vec::new();
let mut progress = |current, total| {
reports.push((current, total));
Box::pin(async { Ok(()) })
as std::pin::Pin<
Box<
dyn std::future::Future<Output = crate::Result<()>>
+ Send,
>,
>
};
let result = download(
&connection,
&uri,
&request,
&route,
&destination,
&part_path,
data.len() as u64,
8,
Some(&mut progress),
)
.await;
assert!(matches!(result, H2DownloadOutcome::Completed(_)));
let size = data.len() as u64;
assert!(reports.iter().any(|&(current, total)| current > 0
&& current < size
&& total == size));
assert_eq!(reports.last(), Some(&(size, size)));
assert_eq!(request_count.load(Ordering::Relaxed), 8);
assert_eq!(tokio::fs::read(destination).await.unwrap(), *data);
client_driver.abort();
server.abort();
}
#[tokio::test]
async fn cancellation_stops_all_ranges_and_removes_partial_output() {
let request_count = Arc::new(AtomicU64::new(0));
let (client_io, server_io) = tokio::io::duplex(256 * 1024);
let server_requests = Arc::clone(&request_count);
let server = tokio::spawn(async move {
let mut connection =
h2::server::handshake(server_io).await.unwrap();
while let Some(result) = connection.accept().await {
let (request, mut respond) = result.unwrap();
let requests = Arc::clone(&server_requests);
tokio::spawn(async move {
requests.fetch_add(1, Ordering::Relaxed);
let value = request.headers()[RANGE].to_str().unwrap();
let value = value.strip_prefix("bytes=").unwrap();
let (start, end) = value.split_once('-').unwrap();
let response = Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(
http::header::CONTENT_RANGE,
format!("bytes {start}-{end}/2097152"),
)
.body(())
.unwrap();
let _stream =
respond.send_response(response, false).unwrap();
futures::future::pending::<()>().await;
});
}
});
let (sender, driver) = h2::client::handshake(client_io).await.unwrap();
let client_driver = tokio::spawn(async move { driver.await });
let connection = Arc::new(SharedH2Connection::for_test(sender));
let directory = tempfile::tempdir().unwrap();
let destination = directory.path().join("pack.zip");
let part_path = directory.path().join("pack.zip.part");
let cancellation = tokio_util::sync::CancellationToken::new();
let mut request = DownloadRequest::new(
"https://h2-range.test/pack.zip",
fetch::ResourceClass::CurseForge,
)
.with_integrity(fetch::Integrity::default().with_size(2 * 1024 * 1024));
request.cancellation = Some(cancellation.clone());
let route = DownloadRoute {
url: request.url.clone(),
source: fetch::DownloadRouteSource::Official,
is_mirror: false,
allow_sensitive_headers: true,
supports_range: true,
proxy: fetch::ProxyPolicy::Direct,
};
let uri = route.url.parse().unwrap();
let part_for_task = part_path.clone();
let destination_for_task = destination.clone();
let download_task = tokio::spawn(async move {
download(
&connection,
&uri,
&request,
&route,
&destination_for_task,
&part_for_task,
2 * 1024 * 1024,
16,
None,
)
.await
});
tokio::time::timeout(std::time::Duration::from_secs(2), async {
while request_count.load(Ordering::Relaxed) < 16 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
cancellation.cancel();
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(2),
download_task,
)
.await
.unwrap()
.unwrap();
assert!(matches!(outcome, H2DownloadOutcome::Canceled));
assert!(!part_path.exists());
assert!(!destination.exists());
client_driver.abort();
server.abort();
}
}