Files
Starlight_Lancher/packages/app-lib/src/util/download/h2_download.rs
Disy920 fa96226beb
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
Deploy telemetry dashboard / deploy (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
refactor: remove telemetry and fix launcher workflows
2026-09-13 20:32:57 +08:00

1276 lines
44 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,
) -> 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,
)
.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,
)
.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,
) -> 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 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);
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::*;
#[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(())
}