Files
Starlight_Lancher/packages/app-lib/src/util/download/h2_download.rs
Disy bc904065c3
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
feat: complete hosted mod sync and launcher interface updates
Add pack sync markers, tagged mod updates, parallel progress, JWT downloads and retry recovery. Include pending onboarding, about scene, compatibility data pack and download fixes.
2026-09-15 19:06:56 +08:00

1376 lines
48 KiB
Rust

//! HTTP/2 multiplexed file downloads over shared per-authority connections.
//!
//! General downloads to the same authority reuse one long-lived HTTP/2
//! connection. Minecraft assets use the dedicated batch multiplexer below,
//! which can add one sibling connection only after sustained saturation.
use super::h2_pool::{H2ConnectFailureKind, SharedH2Connection};
use crate::util::fetch;
use crate::util::fetch::{
DownloadRequest, DownloadResult, DownloadRoute, Integrity,
};
use futures::StreamExt;
use http::header::{ACCEPT_ENCODING, RANGE, USER_AGENT};
use http::{HeaderMap, HeaderValue, Method, StatusCode, Uri};
use std::path::Path;
use std::pin::Pin;
use std::str::FromStr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex as AsyncMutex;
use url::Url;
/// Logical worker target for the batch asset downloader. Actual H2 stream
/// admission is separately capped so assets cannot starve ordinary content.
pub(crate) const ASSET_BATCH_CONCURRENCY: usize = 256;
/// Internal retry passes for failed batch items before they are handed back
/// to the caller for the regular per-file download path.
const ASSET_BATCH_RETRY_PASSES: usize = 2;
/// Only expand a busy batch after the first connection has had time to warm
/// up. This avoids extra handshakes for the common small/low-latency batch.
const ASSET_BATCH_EXPANSION_DELAY: Duration = Duration::from_millis(500);
/// Expansion is useful only when the primary is close to the authority-wide
/// stream budget (currently 32). The remaining streams can then be assigned
/// to a separate TCP congestion domain.
const ASSET_BATCH_EXPANSION_STREAMS: usize = 24;
const ASSET_RESOURCE_WAIT_TIMEOUT: Duration = Duration::from_secs(45);
fn should_expand_asset_batch_connection(
elapsed: Duration,
primary_active_streams: usize,
) -> bool {
elapsed >= ASSET_BATCH_EXPANSION_DELAY
&& primary_active_streams >= ASSET_BATCH_EXPANSION_STREAMS
}
/// Outcome of attempting a multiplexed download.
pub(crate) enum H2DownloadOutcome {
/// The download completed through the multiplexed path.
Completed(DownloadResult),
/// The install job canceled this transfer; do not enter fallback.
Canceled,
/// The multiplexed path cannot be used; the caller should fall back to
/// the legacy path.
Fallback {
failure: H2DownloadFailure,
preserve_partial: bool,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum H2DownloadFailure {
Ineligible(&'static str),
Connect,
Tls,
Protocol,
Http,
Integrity,
Content,
Io,
Slow,
}
impl H2DownloadFailure {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Ineligible(reason) => reason,
Self::Connect => "HTTP/2 TCP connection failed",
Self::Tls => "HTTP/2 TLS connection failed",
Self::Protocol => "HTTP/2 protocol failed",
Self::Http => "HTTP/2 response was unsuccessful",
Self::Integrity => "HTTP/2 integrity validation failed",
Self::Content => "HTTP/2 content validation failed",
Self::Io => "HTTP/2 local I/O failed",
Self::Slow => "HTTP/2 single stream stayed below expectation",
}
}
pub(crate) const fn should_cooldown_authority(self) -> bool {
matches!(self, Self::Protocol)
}
pub(crate) const fn is_transfer_failure(self) -> bool {
matches!(self, Self::Connect | Self::Tls | Self::Protocol)
}
pub(crate) const fn integrity_failure(self) -> bool {
matches!(self, Self::Integrity | Self::Content)
}
}
/// Attempts to download `request` as one stream on a shared HTTP/2 connection.
pub(crate) async fn try_download_via_h2(
request: &DownloadRequest,
route: &DownloadRoute,
destination: &Path,
part_path: &Path,
policy: super::native::NativeH2Policy,
progress: Option<&mut fetch::FetchProgressFn<'_>>,
) -> H2DownloadOutcome {
if request
.cancellation
.as_ref()
.is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
{
return H2DownloadOutcome::Canceled;
}
if let Some(reason) = super::native::h2_ineligible_reason(route) {
return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Ineligible(reason.as_str()),
preserve_partial: false,
};
}
let connection = match connect_authority(
route,
true,
policy.allow_cold_connection,
)
.await
{
Ok(connection) => connection,
Err(failure) => {
return H2DownloadOutcome::Fallback {
failure,
preserve_partial: false,
};
}
};
let Ok(uri) = route.url.parse::<Uri>() else {
return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Http,
preserve_partial: false,
};
};
let integrity = request.integrity.clone();
let expected_size = integrity.size;
fetch::record_install_download_started(request, route, 1, 1).await;
// When the size is known (Modrinth metadata provides it) skip the probe
// entirely: small files fetch the body directly, large files split into
// range streams right away. The probe is only used when the size must be
// discovered from the server.
let total_size = if let Some(size) = expected_size {
size
} else {
let _probe_stream_permit = match tokio::time::timeout(
ASSET_RESOURCE_WAIT_TIMEOUT,
super::h2_stream_budget::acquire(route),
)
.await
{
Ok(Ok(permit)) => permit,
Ok(Err(_)) | Err(_) => {
return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Connect,
preserve_partial: false,
};
}
};
let mut probe_headers = request_headers(request, route);
probe_headers.insert(RANGE, HeaderValue::from_static("bytes=0-0"));
probe_headers
.insert(ACCEPT_ENCODING, HeaderValue::from_static("identity"));
let (response, mut probe_body) =
match open_stream(&connection, &uri, probe_headers).await {
Ok(pair) => pair,
Err(error) => {
tracing::debug!(
url = %fetch::sanitize_url_for_log(&request.url),
error = %error,
"HTTP/2 probe failed; falling back to legacy download"
);
return H2DownloadOutcome::Fallback {
failure: classify_download_error(&error),
preserve_partial: false,
};
}
};
let status = response.status();
let headers = response.headers();
let total_size = parse_content_range_total(headers)
.or_else(|| parse_content_length(headers));
// Drain the probe body so the stream slot is released.
drain_body(&mut probe_body).await;
drop(probe_body);
let Some(total_size) = total_size else {
return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Http,
preserve_partial: false,
};
};
if total_size == 0 {
return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Content,
preserve_partial: false,
};
}
if status != StatusCode::PARTIAL_CONTENT {
return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Http,
preserve_partial: false,
};
}
total_size
};
if let Some(concurrency) = request.h2_range_concurrency {
return super::h2_range::download(
&connection,
&uri,
request,
route,
destination,
part_path,
total_size,
concurrency,
progress,
)
.await;
}
record_install_stage(
request,
crate::install::DownloadItemStatus::WaitingForResource,
)
.await;
let stream_wait = tokio::time::timeout(
ASSET_RESOURCE_WAIT_TIMEOUT,
super::h2_stream_budget::acquire(route),
);
let stream_wait_started = Instant::now();
let stream_result = if let Some(cancellation) =
request.cancellation.as_ref()
{
tokio::select! {
_ = cancellation.cancelled() => return H2DownloadOutcome::Canceled,
result = stream_wait => result,
}
} else {
stream_wait.await
};
let _stream_permit = match stream_result {
Ok(Ok(permit)) => permit,
Ok(Err(_)) | Err(_) => {
return H2DownloadOutcome::Fallback {
failure: H2DownloadFailure::Connect,
preserve_partial: false,
};
}
};
tracing::debug!(
route = %fetch::sanitize_url_for_log(&route.url),
resource = "h2_stream",
wait_ms = stream_wait_started.elapsed().as_millis(),
"Acquired native H2 stream resource"
);
record_install_stage(
request,
crate::install::DownloadItemStatus::Downloading,
)
.await;
let result = single_stream(
&connection,
&uri,
request,
route,
destination,
part_path,
&integrity,
total_size,
policy,
progress,
)
.await;
match result {
Ok(result) => H2DownloadOutcome::Completed(result),
Err(error) => {
let failure = classify_download_error(&error);
tracing::debug!(
url = %fetch::sanitize_url_for_log(&request.url),
error = %error,
"Multiplexed download failed; falling back to legacy download"
);
H2DownloadOutcome::Fallback {
failure,
preserve_partial: integrity.supports_resume()
&& matches!(failure, H2DownloadFailure::Protocol),
}
}
}
}
async fn connect_authority(
route: &DownloadRoute,
reserve_native_budget: bool,
allow_cold_connection: bool,
) -> Result<Arc<SharedH2Connection>, H2DownloadFailure> {
let authority =
fetch::url_authority(&route.url).ok_or(H2DownloadFailure::Http)?;
match super::h2_pool::shared_connection(
route,
reserve_native_budget,
allow_cold_connection,
)
.await
{
Ok(connection) => Ok(connection),
Err(error) => {
tracing::debug!(
authority,
error = %error,
"Failed to establish shared HTTP/2 connection"
);
Err(match error.kind {
H2ConnectFailureKind::Tcp => H2DownloadFailure::Connect,
H2ConnectFailureKind::Tls => H2DownloadFailure::Tls,
H2ConnectFailureKind::Protocol => H2DownloadFailure::Protocol,
})
}
}
}
fn classify_download_error(error: &crate::Error) -> H2DownloadFailure {
if fetch::is_integrity_error(error) {
return H2DownloadFailure::Integrity;
}
match error.raw.as_ref() {
crate::ErrorKind::HttpError { .. }
| crate::ErrorKind::LabrinthError(_) => H2DownloadFailure::Http,
crate::ErrorKind::IOError(_) | crate::ErrorKind::StdIOError(_) => {
H2DownloadFailure::Io
}
crate::ErrorKind::JSONError(_) => H2DownloadFailure::Content,
crate::ErrorKind::NetworkError(message)
if message.contains("below expectation") =>
{
H2DownloadFailure::Slow
}
crate::ErrorKind::NetworkError(message)
if message.contains("HTTP/2")
|| message.contains("range stream") =>
{
H2DownloadFailure::Protocol
}
crate::ErrorKind::OtherError(message)
if message.contains("HTTP/2")
|| message.contains("Content-Range")
|| message.contains("segment") =>
{
H2DownloadFailure::Protocol
}
crate::ErrorKind::OtherError(message)
if message.contains("empty") || message.contains("Invalid JAR") =>
{
H2DownloadFailure::Content
}
_ => H2DownloadFailure::Http,
}
}
pub(crate) fn request_headers(
request: &DownloadRequest,
route: &DownloadRoute,
) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
HeaderValue::from_str(&crate::launcher_user_agent())
.unwrap_or_else(|_| HeaderValue::from_static("Axolotl Launcher")),
);
let route_host = Url::parse(&route.url)
.ok()
.and_then(|url| url.host_str().map(str::to_string));
if let Some((name, value)) = &request.header
&& (route.allow_sensitive_headers || !fetch::is_sensitive_header(name))
&& (!name.eq_ignore_ascii_case("x-api-key")
|| route_host.as_deref() == Some("api.curseforge.com"))
{
if let Ok(name) = http::header::HeaderName::from_str(name) {
if let Ok(value) = HeaderValue::from_str(value) {
headers.insert(name, value);
}
}
}
headers
}
fn parse_content_length(headers: &HeaderMap) -> Option<u64> {
headers
.get(http::header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok())
}
fn parse_content_range_total(headers: &HeaderMap) -> Option<u64> {
let value = headers.get(http::header::CONTENT_RANGE)?.to_str().ok()?;
let (_, total) = value.split_once('/')?;
if total == "*" {
return None;
}
total.parse().ok()
}
type StreamPair = (http::Response<()>, h2::RecvStream);
pub(crate) async fn open_stream(
connection: &SharedH2Connection,
uri: &Uri,
headers: HeaderMap,
) -> crate::Result<StreamPair> {
let mut request = http::Request::builder()
.method(Method::GET)
.uri(uri.clone())
.version(http::Version::HTTP_2)
.body(())
.unwrap();
*request.headers_mut() = headers;
let response = connection.open(request).await.map_err(|error| {
crate::ErrorKind::NetworkError(format!("HTTP/2 stream error: {error}"))
})?;
let (parts, body) = response.into_parts();
let response = http::Response::from_parts(parts, ());
Ok((response, body))
}
async fn drain_body(stream: &mut h2::RecvStream) {
loop {
let chunk =
match super::h2_receive::receive_chunk(stream, "probe").await {
Ok(Some(chunk)) => chunk,
Ok(None) | Err(_) => break,
};
if super::h2_receive::release_capacity(stream, chunk.len()).is_err() {
break;
}
}
}
/// Downloads a single-stream body to `part_path`, hashing as it streams,
/// then verifies and finalises.
async fn single_stream(
connection: &SharedH2Connection,
uri: &Uri,
request: &DownloadRequest,
route: &DownloadRoute,
destination: &Path,
part_path: &Path,
integrity: &Integrity,
total_size: u64,
policy: super::native::NativeH2Policy,
mut progress: Option<&mut fetch::FetchProgressFn<'_>>,
) -> crate::Result<DownloadResult> {
let mut headers = request_headers(request, route);
headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("identity"));
let (response, mut stream) = open_stream(connection, uri, headers).await?;
if !response.status().is_success() {
let current_url = Url::parse(uri.to_string().as_str()).ok();
if response.status().is_redirection()
&& current_url.as_ref().is_some_and(|current_url| {
crate::util::download::modrinth_redirect::is_tianpao_official_redirect(
current_url,
response
.headers()
.get(http::header::LOCATION)
.and_then(|location| location.to_str().ok()),
)
})
{
return Err(crate::ErrorKind::OtherError(
"Tianpao redirected Modrinth content to the official CDN".to_string(),
)
.into());
}
return Err(crate::ErrorKind::HttpError {
status: response.status().as_u16(),
method: "GET".to_string(),
url: fetch::sanitize_url_for_log(uri.to_string().as_str()),
}
.into());
}
let mut hashers = fetch::IntegrityHashers::new_integrity_hashers(integrity);
let mut file = tokio::fs::File::create(part_path).await?;
let mut downloaded = 0_u64;
let activity = super::h2_receive::H2TransferActivity::begin();
let mut progress_gate = super::h2_receive::H2ProgressGate::new(total_size);
let mut slow_policy =
super::native_slow::NativeSlowPolicy::new(0, policy.expected_speed);
loop {
let chunk =
super::h2_receive::receive_chunk(&mut stream, "file").await?;
let Some(chunk) = chunk else {
break;
};
file.write_all(&chunk).await?;
hashers.update(&chunk);
downloaded += chunk.len() as u64;
activity.record_bytes(chunk.len());
super::h2_receive::release_capacity(&mut stream, chunk.len())?;
if progress_gate.should_report(downloaded, total_size) {
record_install_progress(request, downloaded, total_size).await;
if let Some(callback) = progress.as_deref_mut() {
callback(downloaded, total_size).await?;
}
}
if policy.abort_if_slow
&& matches!(
slow_policy.observe(
downloaded,
total_size.saturating_sub(downloaded),
),
super::native_slow::SlowDecision::Probe { .. }
| super::native_slow::SlowDecision::Idle { .. }
)
{
return Err(crate::ErrorKind::NetworkError(
"HTTP/2 single stream stayed below expectation".to_string(),
)
.into());
}
}
file.flush().await?;
drop(file);
if let Some(callback) = progress.as_deref_mut() {
callback(downloaded, total_size).await?;
}
let computed = hashers.finish(downloaded);
record_install_stage(
request,
crate::install::DownloadItemStatus::Verifying,
)
.await;
verify_and_finalize(
part_path,
destination,
integrity,
computed,
downloaded,
total_size,
)
.await?;
Ok(DownloadResult {
path: destination.to_path_buf(),
url: uri.to_string(),
source: route.source,
size: downloaded,
attempts: 1,
fallback_count: 0,
})
}
pub(crate) async fn record_install_stage(
request: &DownloadRequest,
status: crate::install::DownloadItemStatus,
) {
if let Some(tracking) = &request.install_tracking {
let reporter = tracking.reporter.clone();
let item_id = tracking.item_id.clone();
let _ = reporter.record_download_stage(item_id, status).await;
}
}
pub(crate) async fn record_install_progress(
request: &DownloadRequest,
downloaded: u64,
total_size: u64,
) {
if let Some(tracking) = &request.install_tracking {
let reporter = tracking.reporter.clone();
let item_id = tracking.item_id.clone();
let _ = reporter
.record_download_progress(item_id, downloaded, total_size)
.await;
}
}
async fn verify_and_finalize(
part_path: &Path,
destination: &Path,
integrity: &Integrity,
hashers: fetch::ComputedIntegrity,
downloaded: u64,
_expected_size: u64,
) -> crate::Result<()> {
// The size check lives inside `verify_computed_integrity`: the hash is
// authoritative whenever one is available, mirroring the legacy path.
if let Err(error) = fetch::verify_computed_integrity(integrity, &hashers) {
return Err(error);
}
if let Err(error) =
fetch::validate_file_content(part_path, integrity.content).await
{
return Err(error);
}
if downloaded == 0 {
return Err(crate::ErrorKind::OtherError(
"downloaded file is empty".to_string(),
)
.into());
}
fetch::finalize_download(part_path, destination).await?;
Ok(())
}
/// A single small file (a Minecraft asset object) to download over a shared
/// HTTP/2 connection. All items in one batch must share the same authority.
pub(crate) struct H2BatchAsset {
/// Canonical asset URL used to rebuild routes after batch failure.
pub original_url: String,
/// Route-resolved URL for the asset.
pub url: String,
/// Destination for the object (`assets/objects/<hh>/<hash>`).
pub destination: std::path::PathBuf,
/// Legacy `resources/` copies to create after the object is committed.
/// Several logical asset names may point to this one physical object.
pub legacy_destinations: Vec<std::path::PathBuf>,
/// Expected SHA-1 hash of the asset (also its file name).
pub sha1: String,
/// Expected size in bytes.
pub size: u64,
/// Number of logical index entries represented by this physical object.
/// Progress remains index-based even when duplicate objects are coalesced.
pub logical_items: u32,
}
/// Completion state for one physical asset object. A legacy-resource copy is
/// deliberately separate from fetching and committing the content-addressed
/// object: a local path error must not cause another GET for an object that is
/// already valid on disk.
enum AssetBatchItemOutcome {
Completed {
downloaded: bool,
},
LocalCopyFailed {
downloaded: bool,
error: crate::Error,
},
LocalObjectFailed {
error: crate::Error,
},
/// The server answered with a redirect status that only the regular
/// per-file download layer can explain: it owns Location following, hop
/// limits and route selection. The item is handed straight back to that
/// layer, never counted as a line-level transfer failure and never
/// retried inside the batch.
RedirectFallback,
}
impl Clone for H2BatchAsset {
fn clone(&self) -> Self {
Self {
original_url: self.original_url.clone(),
url: self.url.clone(),
destination: self.destination.clone(),
legacy_destinations: self.legacy_destinations.clone(),
sha1: self.sha1.clone(),
size: self.size,
logical_items: self.logical_items,
}
}
}
/// Selects the least busy connection in an asset batch. A sibling connection
/// is created once, at most, when the initial connection remains saturated
/// beyond the warm-up period; this keeps the normal case at one TCP/TLS
/// connection while giving a degraded long batch an independent recovery and
/// congestion domain.
struct AssetBatchConnectionGroup {
primary: Arc<SharedH2Connection>,
sibling: AsyncMutex<Option<Arc<SharedH2Connection>>>,
expansion_attempted: AtomicBool,
route: DownloadRoute,
reserve_native_budget: bool,
started: Instant,
}
impl AssetBatchConnectionGroup {
fn new(
primary: Arc<SharedH2Connection>,
route: &DownloadRoute,
reserve_native_budget: bool,
) -> Self {
Self {
primary,
sibling: AsyncMutex::new(None),
expansion_attempted: AtomicBool::new(false),
route: route.clone(),
reserve_native_budget,
started: Instant::now(),
}
}
fn should_expand(&self) -> bool {
should_expand_asset_batch_connection(
self.started.elapsed(),
self.primary.active_streams(),
)
}
async fn connection(&self, rescue: bool) -> Arc<SharedH2Connection> {
if (rescue || self.should_expand())
&& self
.expansion_attempted
.compare_exchange(
false,
true,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
match super::h2_pool::shared_batch_connection(
&self.route,
self.reserve_native_budget,
)
.await
{
Ok(connection) => {
tracing::info!(
authority = %fetch::url_authority(&self.route.url).unwrap_or_default(),
primary_active_streams = self.primary.active_streams(),
"Expanded saturated HTTP/2 asset batch with a sibling connection"
);
*self.sibling.lock().await = Some(connection);
}
Err(error) => {
tracing::debug!(
authority = %fetch::url_authority(&self.route.url).unwrap_or_default(),
error = %error,
"Could not expand HTTP/2 asset batch; retaining primary connection"
);
}
}
}
let sibling = self.sibling.lock().await.clone();
match sibling {
Some(sibling) if rescue && !sibling.is_dead() => sibling,
Some(sibling)
if !sibling.is_dead()
&& sibling.active_streams()
< self.primary.active_streams() =>
{
sibling
}
_ => Arc::clone(&self.primary),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
#[tokio::test]
async fn single_stream_reports_bytes_before_the_response_finishes() {
let size = 512 * 1024;
let data = Bytes::from(vec![42; size]);
let progress_seen = Arc::new(tokio::sync::Notify::new());
let server_progress = Arc::clone(&progress_seen);
let server_data = data.clone();
let (client_io, server_io) = tokio::io::duplex(1024 * 1024);
let server = tokio::spawn(async move {
let mut connection =
h2::server::handshake(server_io).await.unwrap();
while let Some(result) = connection.accept().await {
let (_, mut respond) = result.unwrap();
let data = server_data.clone();
let progress = Arc::clone(&server_progress);
tokio::spawn(async move {
let mut stream = respond
.send_response(http::Response::new(()), false)
.unwrap();
stream.send_data(data.slice(..size / 2), false).unwrap();
progress.notified().await;
stream.send_data(data.slice(size / 2..), true).unwrap();
});
}
});
let mut builder = h2::client::Builder::new();
builder.initial_window_size(1024 * 1024);
let (sender, driver) =
builder.handshake::<_, Bytes>(client_io).await.unwrap();
let client = tokio::spawn(driver);
let connection = SharedH2Connection::for_test(sender);
let directory = tempfile::tempdir().unwrap();
let destination = directory.path().join("pack.zip");
let part = directory.path().join("pack.zip.part");
let integrity =
Integrity::sha1(sha1_smol::Sha1::from(&data[..]).hexdigest())
.with_size(size as u64);
let request = DownloadRequest::new(
"https://h2-progress.test/pack.zip",
fetch::ResourceClass::Modpack,
)
.with_integrity(integrity.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 mut reports = Vec::new();
let mut progress = |current, total| {
reports.push((current, total));
progress_seen.notify_one();
Box::pin(async { Ok(()) })
as Pin<Box<dyn Future<Output = crate::Result<()>> + Send>>
};
let result = tokio::time::timeout(
Duration::from_secs(5),
single_stream(
&connection,
&request.url.parse().unwrap(),
&request,
&route,
&destination,
&part,
&integrity,
size as u64,
super::super::native::NativeH2Policy {
allow_cold_connection: true,
abort_if_slow: false,
expected_speed: None,
},
Some(&mut progress),
),
)
.await;
client.abort();
server.abort();
result.unwrap().unwrap();
assert!(
reports
.iter()
.any(|&(current, total)| current > 0 && current < total)
);
assert_eq!(reports.last(), Some(&(size as u64, size as u64)));
assert_eq!(tokio::fs::read(destination).await.unwrap(), data);
}
#[test]
fn asset_batch_expansion_requires_sustained_saturation() {
assert!(!should_expand_asset_batch_connection(
ASSET_BATCH_EXPANSION_DELAY,
ASSET_BATCH_EXPANSION_STREAMS - 1,
));
assert!(!should_expand_asset_batch_connection(
ASSET_BATCH_EXPANSION_DELAY - Duration::from_millis(1),
ASSET_BATCH_EXPANSION_STREAMS,
));
assert!(should_expand_asset_batch_connection(
ASSET_BATCH_EXPANSION_DELAY,
ASSET_BATCH_EXPANSION_STREAMS,
));
}
#[tokio::test]
async fn committed_asset_recovers_a_legacy_copy_without_redownloading() {
let temp = tempfile::tempdir().unwrap();
let destination = temp.path().join("object");
tokio::fs::write(&destination, b"already committed")
.await
.unwrap();
let blocked_parent = temp.path().join("legacy-parent");
tokio::fs::write(&blocked_parent, b"not a directory")
.await
.unwrap();
let legacy = blocked_parent.join("resource");
let item = H2BatchAsset {
original_url: "https://resources.download.minecraft.net/aa/object"
.into(),
url: "https://resources.download.minecraft.net/aa/object".into(),
destination: destination.clone(),
legacy_destinations: vec![legacy.clone()],
sha1: "unused-by-copy-test".into(),
size: 17,
logical_items: 1,
};
assert!(copy_asset_legacy_destinations(&item).await.is_err());
assert_eq!(
tokio::fs::read(&destination).await.unwrap(),
b"already committed"
);
tokio::fs::remove_file(&blocked_parent).await.unwrap();
tokio::fs::create_dir(&blocked_parent).await.unwrap();
copy_asset_legacy_destinations(&item).await.unwrap();
assert_eq!(
tokio::fs::read(&legacy).await.unwrap(),
b"already committed"
);
}
#[tokio::test]
async fn blocked_object_parent_is_detected_before_asset_get() {
let temp = tempfile::tempdir().unwrap();
let blocked_parent = temp.path().join("object-parent");
tokio::fs::write(&blocked_parent, b"not a directory")
.await
.unwrap();
let destination = blocked_parent.join("object");
let part_path = fetch::suffixed_path(&destination, ".part");
assert!(prepare_asset_part_path(&destination).await.is_err());
assert!(tokio::fs::metadata(&part_path).await.is_err());
}
}
/// Downloads a batch of small files over a shared HTTP/2 connection group,
/// multiplexing up to `concurrency` logical workers. Physical H2 streams are
/// governed by the dedicated asset stream budget. The group begins with one
/// connection and may add one sibling only for a sustained saturated batch;
/// it never creates one connection per file. Items that cannot be downloaded
/// after internal retries are returned so the caller can retry them through
/// the regular per-file path (which performs route fallback).
/// Returned items have exhausted every batch pass, so downstream can treat
/// them as persistently failing against the chosen route.
pub(crate) async fn download_asset_batch_via_h2<F>(
route: &DownloadRoute,
items: Vec<H2BatchAsset>,
concurrency: usize,
apply_native_policy: bool,
native_semaphore: Option<&fetch::FetchSemaphore>,
on_completed: F,
) -> crate::Result<Vec<H2BatchAsset>>
where
F: Fn(H2BatchAsset) -> Pin<Box<dyn Future<Output = ()> + Send>>
+ Send
+ Sync
+ 'static,
{
let concurrency = concurrency.max(1);
if apply_native_policy
&& super::native::h2_ineligible_reason(route).is_some()
{
return Ok(items);
}
// Local object I/O failures are deterministic per destination; remember the
// first one and keep draining the batch so siblings already in flight or
// still queued are not abandoned, then surface the error to the caller.
let mut local_object_error: Option<crate::Error> = None;
let connection =
match connect_authority(route, apply_native_policy, true).await {
Ok(connection) => connection,
Err(failure) => {
if apply_native_policy
&& failure.should_cooldown_authority()
&& let Some(authority) = fetch::url_authority(&route.url)
{
fetch::record_authority_h2_failure(&authority);
}
if apply_native_policy && failure.is_transfer_failure() {
super::native_breaker::record_failure(route);
fetch::record_route_health_failure(
route,
fetch::ResourceClass::MinecraftAsset,
None,
);
}
return Ok(items);
}
};
let connections = Arc::new(AssetBatchConnectionGroup::new(
connection,
route,
apply_native_policy,
));
let route_authority = fetch::url_authority(&route.url);
// Items whose URL targets a different authority cannot be multiplexed on
// this connection; hand them straight back without wasting batch passes.
let (mut items, mut failed): (Vec<_>, Vec<_>) = items
.into_iter()
.partition(|item| fetch::url_authority(&item.url) == route_authority);
if !failed.is_empty() {
tracing::warn!(
items = failed.len(),
route = %fetch::sanitize_url_for_log(&route.url),
"Skipping {} assets whose resolved URL does not match the batch authority",
failed.len(),
);
}
let batch_started = std::time::Instant::now();
let mut completed_bytes = 0_u64;
let mut network_failures = 0_u32;
let callback = Arc::new(on_completed);
for pass in 0..ASSET_BATCH_RETRY_PASSES {
if items.is_empty() {
break;
}
let results = futures::stream::iter(items)
.map(|item| {
let connections = Arc::clone(&connections);
let callback = callback.clone();
async move {
let Ok(uri) = item.url.parse::<Uri>() else {
let error =
crate::Error::from(crate::ErrorKind::InputError(
format!("invalid asset URL: {}", item.url),
));
return (item, Err(error));
};
let result = download_asset_item(
&connections,
&uri,
&item,
route,
apply_native_policy,
native_semaphore,
pass > 0,
)
.await;
if matches!(
&result,
Ok(AssetBatchItemOutcome::Completed { .. })
) {
callback(item.clone()).await;
}
(item, result)
}
})
.buffer_unordered(concurrency)
.collect::<Vec<_>>()
.await;
items = Vec::new();
for (item, result) in results {
match result {
Ok(AssetBatchItemOutcome::Completed { downloaded }) => {
if downloaded {
completed_bytes =
completed_bytes.saturating_add(item.size);
}
}
Ok(AssetBatchItemOutcome::LocalCopyFailed {
downloaded,
error,
}) => {
if downloaded {
completed_bytes =
completed_bytes.saturating_add(item.size);
}
tracing::warn!(
url = %fetch::sanitize_url_for_log(&item.url),
destination = %item.destination.display(),
error = %error,
"Asset object is committed, but copying its legacy resource failed; retrying locally without another download"
);
// The ordinary fallback path sees the valid object and
// performs only the outstanding local copy. Do not spend
// another network retry pass on a local filesystem error.
failed.push(item);
}
Ok(AssetBatchItemOutcome::LocalObjectFailed { error }) => {
tracing::warn!(
url = %fetch::sanitize_url_for_log(&item.url),
destination = %item.destination.display(),
error = %error,
"Asset object failed local I/O; continuing to drain the batch"
);
if local_object_error.is_none() {
local_object_error = Some(error);
}
}
Ok(AssetBatchItemOutcome::RedirectFallback) => {
tracing::debug!(
url = %fetch::sanitize_url_for_log(&item.url),
"Asset batch received a redirect status; deferring to the regular download path"
);
// Redirect responses are explained by the regular per-file
// layer (Location following, hop limit, route fallback).
// Do not spend another batch pass on them and do not count
// them as transfer failures: #487 keeps original_url so the
// ordinary path can rebuild official and mirror candidates.
failed.push(item);
}
Err(error) => {
network_failures = network_failures.saturating_add(1);
tracing::debug!(
url = %fetch::sanitize_url_for_log(&item.url),
pass = pass + 1,
error = %error,
"Batch asset download failed"
);
if pass + 1 < ASSET_BATCH_RETRY_PASSES {
items.push(item);
} else {
failed.push(item);
}
}
}
}
}
if apply_native_policy {
if completed_bytes > 0 {
fetch::record_route_transfer_success(
route,
fetch::ResourceClass::MinecraftAsset,
completed_bytes,
batch_started.elapsed(),
);
} else if network_failures > 0 {
super::native_breaker::record_failure(route);
fetch::record_route_health_failure(
route,
fetch::ResourceClass::MinecraftAsset,
None,
);
}
}
if let Some(error) = local_object_error {
return Err(error);
}
if !failed.is_empty() {
tracing::warn!(
items = failed.len(),
retry_passes = ASSET_BATCH_RETRY_PASSES,
source = route.source.as_str(),
"{} assets exhausted all {ASSET_BATCH_RETRY_PASSES} batch retry passes on route {}",
failed.len(),
route.source.as_str(),
);
}
Ok(failed)
}
async fn prepare_asset_part_path(
destination: &Path,
) -> crate::Result<std::path::PathBuf> {
let part_path = fetch::suffixed_path(destination, ".part");
if let Some(parent) = part_path.parent() {
crate::util::io::create_dir_all(parent).await?;
}
Ok(part_path)
}
async fn download_asset_item(
connections: &AssetBatchConnectionGroup,
uri: &Uri,
item: &H2BatchAsset,
route: &DownloadRoute,
apply_native_policy: bool,
native_semaphore: Option<&fetch::FetchSemaphore>,
rescue: bool,
) -> crate::Result<AssetBatchItemOutcome> {
let integrity = Integrity {
size: Some(item.size),
sha1: Some(item.sha1.clone()),
..Integrity::default()
};
let destination_lock = fetch::destination_download_lock(&item.destination);
let _destination_guard = tokio::time::timeout(
ASSET_RESOURCE_WAIT_TIMEOUT,
destination_lock.lock(),
)
.await
.map_err(|_| {
crate::ErrorKind::NetworkError(
"timed out waiting for asset destination lock".to_string(),
)
})?;
let fetch_permit = if apply_native_policy {
let Some(semaphore) = native_semaphore else {
return Err(crate::ErrorKind::OtherError(
"native asset batch is missing fetch budget".to_string(),
)
.into());
};
Some(
tokio::time::timeout(
ASSET_RESOURCE_WAIT_TIMEOUT,
semaphore.0.acquire(),
)
.await
.map_err(|_| {
crate::ErrorKind::NetworkError(
"timed out waiting for asset fetch permit".to_string(),
)
})??,
)
} else {
None
};
// A different downloader may have committed the object while this item
// waited for the destination lock. Reuse it instead of opening another
// stream, which also prevents cross-engine `.part`/rename races.
// Existence is checked before full hash verification: `verify_file`
// acquires the global validation budget before it opens the file, so a
// batch full of missing objects must not queue behind unrelated hashing
// work before it can even reach the network stage. Existence alone is
// never treated as correctness; an existing object is still verified.
match tokio::fs::try_exists(&item.destination).await {
Ok(true) => {
if fetch::verify_file(&item.destination, &integrity)
.await
.is_ok()
{
return Ok(match copy_asset_legacy_destinations(item).await {
Ok(()) => {
AssetBatchItemOutcome::Completed { downloaded: false }
}
Err(error) => AssetBatchItemOutcome::LocalCopyFailed {
downloaded: false,
error,
},
});
}
}
Ok(false) => {}
Err(error) => {
return Ok(AssetBatchItemOutcome::LocalObjectFailed {
error: error.into(),
});
}
}
let part_path = match prepare_asset_part_path(&item.destination).await {
Ok(part_path) => part_path,
Err(error) => {
return Ok(AssetBatchItemOutcome::LocalObjectFailed { error });
}
};
let _stream_permit = if apply_native_policy {
Some(
tokio::time::timeout(
ASSET_RESOURCE_WAIT_TIMEOUT,
super::h2_stream_budget::acquire_asset(route),
)
.await
.map_err(|_| {
crate::ErrorKind::NetworkError(
"timed out waiting for asset HTTP/2 stream permit"
.to_string(),
)
})??,
)
} else {
None
};
let connection = connections.connection(rescue).await;
let _connection_stream = connection.track_stream();
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
HeaderValue::from_str(&crate::launcher_user_agent())
.unwrap_or_else(|_| HeaderValue::from_static("Axolotl Launcher")),
);
headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("identity"));
let (response, mut stream) = open_stream(&connection, uri, headers).await?;
drop(fetch_permit);
if !response.status().is_success() {
// 301/302/303/307/308 are redirect responses that must be interpreted
// by the redirect-handling layer, not treated as line-level transfer
// failures. 304 is deliberately excluded: it is not a downloadable
// redirect for these objects.
if matches!(response.status().as_u16(), 301 | 302 | 303 | 307 | 308) {
return Ok(AssetBatchItemOutcome::RedirectFallback);
}
return Err(crate::ErrorKind::OtherError(format!(
"HTTP/2 GET failed with status {}",
response.status()
))
.into());
}
let mut hashers =
fetch::IntegrityHashers::new_integrity_hashers(&integrity);
// Any failure below leaves a partial file behind; clean it up so retries
// start from a clean slate and no orphaned `.part` files accumulate.
let result: crate::Result<AssetBatchItemOutcome> = async {
let mut file = match tokio::fs::File::create(&part_path).await {
Ok(file) => file,
Err(error) => {
return Ok(AssetBatchItemOutcome::LocalObjectFailed {
error: error.into(),
});
}
};
let mut downloaded = 0_u64;
let activity = super::h2_receive::H2TransferActivity::begin();
loop {
let chunk =
super::h2_receive::receive_chunk(&mut stream, "asset").await?;
let Some(chunk) = chunk else {
break;
};
if let Err(error) = file.write_all(&chunk).await {
return Ok(AssetBatchItemOutcome::LocalObjectFailed {
error: error.into(),
});
}
hashers.update(&chunk);
downloaded += chunk.len() as u64;
activity.record_bytes(chunk.len());
super::h2_receive::release_capacity(&mut stream, chunk.len())?;
}
if let Err(error) = file.flush().await {
return Ok(AssetBatchItemOutcome::LocalObjectFailed {
error: error.into(),
});
}
drop(file);
if downloaded == 0 {
return Err(crate::ErrorKind::OtherError(
"downloaded asset is empty".to_string(),
)
.into());
}
let computed = hashers.finish(downloaded);
fetch::verify_computed_integrity(&integrity, &computed)?;
if let Err(error) =
fetch::finalize_download(&part_path, &item.destination).await
{
return Ok(AssetBatchItemOutcome::LocalObjectFailed { error });
}
Ok(match copy_asset_legacy_destinations(item).await {
Ok(()) => AssetBatchItemOutcome::Completed { downloaded: true },
Err(error) => AssetBatchItemOutcome::LocalCopyFailed {
downloaded: true,
error,
},
})
}
.await;
if result.is_err()
|| matches!(
&result,
Ok(AssetBatchItemOutcome::LocalObjectFailed { .. })
)
{
let _ = tokio::fs::remove_file(&part_path).await;
}
result
}
async fn copy_asset_legacy_destinations(
item: &H2BatchAsset,
) -> crate::Result<()> {
for legacy in &item.legacy_destinations {
if let Some(state) = crate::State::get_if_initialized() {
fetch::copy(&item.destination, legacy, &state.io_semaphore).await?;
} else {
if let Some(parent) = legacy.parent() {
crate::util::io::create_dir_all(parent).await?;
}
tokio::fs::copy(&item.destination, legacy).await?;
}
}
Ok(())
}