fix: improve parallel pack downloads and suppress stale notifications
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
Sync LobeHub models / sync (push) Has been cancelled
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
Sync LobeHub models / sync (push) Has been cancelled
Download hosted pack files concurrently, recover file transfers from proxy and content-encoding failures, and prevent old installation failures from reappearing in notifications.
This commit is contained in:
@ -21,6 +21,7 @@ import {
|
|||||||
type InstallPhaseId,
|
type InstallPhaseId,
|
||||||
type InstallProgress,
|
type InstallProgress,
|
||||||
} from '@/helpers/install'
|
} from '@/helpers/install'
|
||||||
|
import { createInstallJobNotificationFilter } from '@/helpers/install-job-notification-visibility'
|
||||||
import { effectiveInstallProgress, hasDeterminateInstallProgress } from '@/helpers/install-progress'
|
import { effectiveInstallProgress, hasDeterminateInstallProgress } from '@/helpers/install-progress'
|
||||||
import { get_many as getInstances } from '@/helpers/instance'
|
import { get_many as getInstances } from '@/helpers/instance'
|
||||||
import type { DownloadManager } from '@/providers/download-manager'
|
import type { DownloadManager } from '@/providers/download-manager'
|
||||||
@ -258,15 +259,6 @@ const failureSummaryMessages = defineMessages({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const visibleJobStatuses = new Set<InstallJobStatus>([
|
|
||||||
'queued',
|
|
||||||
'running',
|
|
||||||
'canceling',
|
|
||||||
'waiting_for_user',
|
|
||||||
'failed',
|
|
||||||
'interrupted',
|
|
||||||
])
|
|
||||||
const retainedJobStatuses = new Set<InstallJobStatus>(['succeeded', 'canceled'])
|
|
||||||
const activeJobStatuses = new Set<InstallJobStatus>([
|
const activeJobStatuses = new Set<InstallJobStatus>([
|
||||||
'queued',
|
'queued',
|
||||||
'running',
|
'running',
|
||||||
@ -724,6 +716,8 @@ export async function useInstallJobNotifications(opts: {
|
|||||||
return buttons
|
return buttons
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filterVisibleJobs = createInstallJobNotificationFilter(opts.manager.jobs.value)
|
||||||
|
|
||||||
function setJobs(nextJobs: InstallJobSnapshot[]) {
|
function setJobs(nextJobs: InstallJobSnapshot[]) {
|
||||||
for (const job of nextJobs) {
|
for (const job of nextJobs) {
|
||||||
if (!jobOrder.has(job.job_id)) {
|
if (!jobOrder.has(job.job_id)) {
|
||||||
@ -731,12 +725,7 @@ export async function useInstallJobNotifications(opts: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentJobIds = new Set(jobs.value.map((job) => job.job_id))
|
const visibleJobs = filterVisibleJobs(nextJobs)
|
||||||
const visibleJobs = nextJobs.filter(
|
|
||||||
(job) =>
|
|
||||||
visibleJobStatuses.has(job.status) ||
|
|
||||||
(retainedJobStatuses.has(job.status) && currentJobIds.has(job.job_id)),
|
|
||||||
)
|
|
||||||
syncProgressSnapshots(visibleJobs)
|
syncProgressSnapshots(visibleJobs)
|
||||||
|
|
||||||
jobs.value = visibleJobs.sort(
|
jobs.value = visibleJobs.sort(
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import type { InstallJobSnapshot, InstallJobStatus } from './install.ts'
|
||||||
|
import { createInstallJobNotificationFilter } from './install-job-notification-visibility.ts'
|
||||||
|
|
||||||
|
function job(jobId: string, status: InstallJobStatus): InstallJobSnapshot {
|
||||||
|
return { job_id: jobId, status } as InstallJobSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
test('does not resurrect failures that finished before the notification surface started', () => {
|
||||||
|
const oldFailure = job('old-failure', 'failed')
|
||||||
|
const filter = createInstallJobNotificationFilter([oldFailure, job('old-success', 'succeeded')])
|
||||||
|
|
||||||
|
assert.deepEqual(filter([oldFailure, job('old-success', 'succeeded')]), [])
|
||||||
|
assert.deepEqual(filter([oldFailure, job('current', 'running')]).map((item) => item.job_id), [
|
||||||
|
'current',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps an observed task visible when it finishes', () => {
|
||||||
|
const filter = createInstallJobNotificationFilter([job('old-failure', 'failed')])
|
||||||
|
|
||||||
|
assert.deepEqual(filter([job('current', 'running')]).map((item) => item.job_id), ['current'])
|
||||||
|
assert.deepEqual(filter([job('current', 'failed')]).map((item) => item.job_id), ['current'])
|
||||||
|
assert.deepEqual(filter([job('current', 'succeeded')]).map((item) => item.job_id), ['current'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shows a newly received failure even if its active phase completed too quickly to observe', () => {
|
||||||
|
const filter = createInstallJobNotificationFilter([job('old-failure', 'failed')])
|
||||||
|
|
||||||
|
assert.deepEqual(filter([job('new-failure', 'failed')]).map((item) => item.job_id), [
|
||||||
|
'new-failure',
|
||||||
|
])
|
||||||
|
})
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import type { InstallJobSnapshot, InstallJobStatus } from './install.ts'
|
||||||
|
|
||||||
|
const activeStatuses = new Set<InstallJobStatus>([
|
||||||
|
'queued',
|
||||||
|
'running',
|
||||||
|
'canceling',
|
||||||
|
'waiting_for_user',
|
||||||
|
])
|
||||||
|
const failureStatuses = new Set<InstallJobStatus>(['failed', 'interrupted'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the popup scoped to work the user could actually have observed.
|
||||||
|
* Finished jobs already present when the action bar starts belong to download
|
||||||
|
* history; they must not be resurrected by an unrelated loading event.
|
||||||
|
*/
|
||||||
|
export function createInstallJobNotificationFilter(initialJobs: InstallJobSnapshot[]) {
|
||||||
|
const missedFinishedJobIds = new Set(
|
||||||
|
initialJobs.filter((job) => !activeStatuses.has(job.status)).map((job) => job.job_id),
|
||||||
|
)
|
||||||
|
let visibleJobIds = new Set<string>()
|
||||||
|
|
||||||
|
return (nextJobs: InstallJobSnapshot[]) => {
|
||||||
|
const visibleJobs = nextJobs.filter((job) => {
|
||||||
|
if (activeStatuses.has(job.status)) return true
|
||||||
|
if (visibleJobIds.has(job.job_id)) return true
|
||||||
|
return failureStatuses.has(job.status) && !missedFinishedJobIds.has(job.job_id)
|
||||||
|
})
|
||||||
|
|
||||||
|
visibleJobIds = new Set(visibleJobs.map((job) => job.job_id))
|
||||||
|
return visibleJobs
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,14 +12,17 @@ use crate::{
|
|||||||
download_to_path,
|
download_to_path,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use futures::FutureExt;
|
use futures::{FutureExt, StreamExt, stream};
|
||||||
use progress::PackProgress;
|
use progress::PackProgress;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, HashSet},
|
collections::{BTreeMap, HashSet},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::{Arc, LazyLock},
|
sync::{
|
||||||
|
Arc, LazyLock,
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use tokio::{
|
use tokio::{
|
||||||
io::AsyncReadExt,
|
io::AsyncReadExt,
|
||||||
@ -968,11 +971,27 @@ async fn synchronize_with_progress(
|
|||||||
.map(|(file, _, _)| file.size)
|
.map(|(file, _, _)| file.size)
|
||||||
.sum::<u64>();
|
.sum::<u64>();
|
||||||
let total_files = pending_downloads.len();
|
let total_files = pending_downloads.len();
|
||||||
let mut completed_bytes = 0;
|
if total_files > 0 {
|
||||||
for (index, (file, url, object)) in
|
let concurrency = crate::api::settings::get()
|
||||||
pending_downloads.into_values().enumerate()
|
.await?
|
||||||
{
|
.effective_max_concurrent_downloads()
|
||||||
ensure_session(&auth).await?;
|
.clamp(1, 16);
|
||||||
|
let current = std::sync::Mutex::new(BTreeMap::<String, u64>::new());
|
||||||
|
let completed = AtomicUsize::new(0);
|
||||||
|
progress.download(
|
||||||
|
0,
|
||||||
|
total_bytes,
|
||||||
|
&format!("文件 0/{total_files}"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
let results = stream::iter(pending_downloads.into_values().map(
|
||||||
|
|(file, url, object)| {
|
||||||
|
let current = ¤t;
|
||||||
|
let completed = &completed;
|
||||||
|
let state = &state;
|
||||||
|
let auth = &auth;
|
||||||
|
async move {
|
||||||
|
ensure_session(auth).await?;
|
||||||
let url = if let Some(external) = &file.external {
|
let url = if let Some(external) = &file.external {
|
||||||
match crate::api::curseforge::get_file(
|
match crate::api::curseforge::get_file(
|
||||||
external.project_id,
|
external.project_id,
|
||||||
@ -988,34 +1007,45 @@ async fn synchronize_with_progress(
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
invalid("This CurseForge file requires a manual download")
|
invalid(
|
||||||
|
"This CurseForge file requires a manual download",
|
||||||
|
)
|
||||||
})?,
|
})?,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
url
|
url
|
||||||
};
|
};
|
||||||
let label =
|
let file_size = file.size;
|
||||||
format!("文件 {}/{} · {}", index + 1, total_files, file.path);
|
let file_path = file.path.clone();
|
||||||
progress.download(completed_bytes, total_bytes, &label, index == 0);
|
let progress_key = file.path.clone();
|
||||||
let mut transferred = 0;
|
|
||||||
let mut on_progress = |received: u64, _total: u64| {
|
let mut on_progress = |received: u64, _total: u64| {
|
||||||
transferred = received.min(file.size);
|
let mut bytes = current
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
bytes.insert(progress_key.clone(), received.min(file_size));
|
||||||
progress.download(
|
progress.download(
|
||||||
completed_bytes + transferred,
|
bytes.values().sum(),
|
||||||
total_bytes,
|
total_bytes,
|
||||||
&label,
|
&format!(
|
||||||
|
"文件 {}/{} · {}",
|
||||||
|
completed.load(Ordering::Relaxed),
|
||||||
|
total_files,
|
||||||
|
file_path
|
||||||
|
),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
futures::future::ready(Ok(())).boxed()
|
futures::future::ready(Ok(())).boxed()
|
||||||
};
|
};
|
||||||
let mut request = DownloadRequest::new(&url, ResourceClass::Modpack)
|
let mut request =
|
||||||
|
DownloadRequest::new(&url, ResourceClass::Modpack)
|
||||||
.with_integrity(Integrity {
|
.with_integrity(Integrity {
|
||||||
size: Some(file.size),
|
size: Some(file_size),
|
||||||
sha256: Some(file.sha256),
|
sha256: Some(file.sha256),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
if url.starts_with(&format!("{API}/files/")) {
|
if url.starts_with(&format!("{API}/files/")) {
|
||||||
request = request.with_header("Authorization", auth.clone());
|
request = request
|
||||||
|
.with_header("Authorization", auth.clone());
|
||||||
}
|
}
|
||||||
download_to_path(
|
download_to_path(
|
||||||
request,
|
request,
|
||||||
@ -1025,14 +1055,33 @@ async fn synchronize_with_progress(
|
|||||||
Some(&mut on_progress),
|
Some(&mut on_progress),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
completed_bytes += file.size;
|
let completed_files =
|
||||||
downloaded += file.size;
|
completed.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
|
let completed_bytes = {
|
||||||
|
let mut bytes = current
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
bytes.insert(progress_key, file_size);
|
||||||
|
bytes.values().sum()
|
||||||
|
};
|
||||||
progress.download(
|
progress.download(
|
||||||
completed_bytes,
|
completed_bytes,
|
||||||
total_bytes,
|
total_bytes,
|
||||||
&label,
|
&format!(
|
||||||
index + 1 == total_files,
|
"文件 {completed_files}/{total_files} · {file_path}"
|
||||||
|
),
|
||||||
|
completed_files == total_files,
|
||||||
);
|
);
|
||||||
|
Ok::<_, crate::Error>(file_size)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.buffer_unordered(concurrency)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.await;
|
||||||
|
downloaded += results.into_iter().try_fold(0, |sum, result| {
|
||||||
|
Ok::<u64, crate::Error>(sum + result?)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
ensure_session(&auth).await?;
|
ensure_session(&auth).await?;
|
||||||
downloaded += tagged::download(
|
downloaded += tagged::download(
|
||||||
|
|||||||
@ -917,6 +917,32 @@ fn official_route(url: &str, resource: ResourceClass) -> DownloadRoute {
|
|||||||
route
|
route
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn add_starlight_direct_recovery_route(routes: &mut Vec<DownloadRoute>) {
|
||||||
|
let recovery_routes = routes
|
||||||
|
.iter()
|
||||||
|
.filter(|route| {
|
||||||
|
route.proxy == ProxyPolicy::System
|
||||||
|
&& Url::parse(&route.url).ok().is_some_and(|url| {
|
||||||
|
url.host_str() == Some("skin.starlight.cool")
|
||||||
|
&& url.path().starts_with("/starlight/mod/packs/files/")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.map(|mut route| {
|
||||||
|
route.proxy = ProxyPolicy::Direct;
|
||||||
|
route
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
for recovery in recovery_routes {
|
||||||
|
if !routes.iter().any(|route| {
|
||||||
|
route.url == recovery.url && route.proxy == recovery.proxy
|
||||||
|
}) {
|
||||||
|
routes.push(recovery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn is_official_route(route: &DownloadRoute) -> bool {
|
fn is_official_route(route: &DownloadRoute) -> bool {
|
||||||
!route.is_mirror && route.source == DownloadRouteSource::Official
|
!route.is_mirror && route.source == DownloadRouteSource::Official
|
||||||
}
|
}
|
||||||
@ -1375,8 +1401,18 @@ fn reqwest_client_builder() -> reqwest::ClientBuilder {
|
|||||||
.user_agent(crate::launcher_user_agent())
|
.user_agent(crate::launcher_user_agent())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn disable_file_content_decoding(
|
||||||
|
builder: reqwest::ClientBuilder,
|
||||||
|
) -> reqwest::ClientBuilder {
|
||||||
|
// File endpoints occasionally arrive through proxies or CDNs with a stale
|
||||||
|
// Content-Encoding header even though the body already contains the raw
|
||||||
|
// file. Keep transport bytes untouched; download integrity validation is
|
||||||
|
// the authoritative check and every request explicitly asks for identity.
|
||||||
|
builder.no_gzip().no_brotli().no_deflate().no_zstd()
|
||||||
|
}
|
||||||
|
|
||||||
fn file_reqwest_client_builder() -> reqwest::ClientBuilder {
|
fn file_reqwest_client_builder() -> reqwest::ClientBuilder {
|
||||||
reqwest_client_builder()
|
disable_file_content_decoding(reqwest_client_builder())
|
||||||
.http2_adaptive_window(true)
|
.http2_adaptive_window(true)
|
||||||
.http2_keep_alive_interval(Some(time::Duration::from_secs(15)))
|
.http2_keep_alive_interval(Some(time::Duration::from_secs(15)))
|
||||||
}
|
}
|
||||||
@ -1406,7 +1442,7 @@ pub async fn configured_client() -> crate::Result<reqwest::Client> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn http1_file_reqwest_client_builder() -> reqwest::ClientBuilder {
|
fn http1_file_reqwest_client_builder() -> reqwest::ClientBuilder {
|
||||||
reqwest_client_builder().http1_only()
|
disable_file_content_decoding(reqwest_client_builder()).http1_only()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub static INSECURE_REQWEST_CLIENT: LazyLock<reqwest::Client> =
|
pub static INSECURE_REQWEST_CLIENT: LazyLock<reqwest::Client> =
|
||||||
@ -3341,6 +3377,21 @@ fn byte_range_header_value(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_file_transport_headers(
|
||||||
|
mut request: reqwest::RequestBuilder,
|
||||||
|
range_start: Option<u64>,
|
||||||
|
range_end: Option<u64>,
|
||||||
|
) -> reqwest::RequestBuilder {
|
||||||
|
// Downloaded files are validated against their unencoded size and hash.
|
||||||
|
// Request the original bytes from the very first attempt so a proxy or CDN
|
||||||
|
// cannot leave reqwest retrying a broken compressed response body.
|
||||||
|
request = request.header(header::ACCEPT_ENCODING, "identity");
|
||||||
|
if let Some(range) = byte_range_header_value(range_start, range_end) {
|
||||||
|
request = request.header(header::RANGE, range);
|
||||||
|
}
|
||||||
|
request
|
||||||
|
}
|
||||||
|
|
||||||
async fn send_path_request_with_clients(
|
async fn send_path_request_with_clients(
|
||||||
route: &DownloadRoute,
|
route: &DownloadRoute,
|
||||||
custom_header: Option<&(String, String)>,
|
custom_header: Option<&(String, String)>,
|
||||||
@ -3383,7 +3434,11 @@ async fn send_path_request_with_clients(
|
|||||||
};
|
};
|
||||||
let same_as_original = same_origin(&original, ¤t);
|
let same_as_original = same_origin(&original, ¤t);
|
||||||
let allow_sensitive = route.allow_sensitive_headers && same_as_original;
|
let allow_sensitive = route.allow_sensitive_headers && same_as_original;
|
||||||
let mut request = client.get(current.clone());
|
let mut request = apply_file_transport_headers(
|
||||||
|
client.get(current.clone()),
|
||||||
|
range_start,
|
||||||
|
range_end,
|
||||||
|
);
|
||||||
if let Some((name, value)) = custom_header
|
if let Some((name, value)) = custom_header
|
||||||
&& (allow_sensitive || !is_sensitive_header(name))
|
&& (allow_sensitive || !is_sensitive_header(name))
|
||||||
&& (!name.eq_ignore_ascii_case("x-api-key")
|
&& (!name.eq_ignore_ascii_case("x-api-key")
|
||||||
@ -3394,11 +3449,6 @@ async fn send_path_request_with_clients(
|
|||||||
if allow_sensitive && let Some(credentials) = credentials {
|
if allow_sensitive && let Some(credentials) = credentials {
|
||||||
request = request.header("Authorization", &credentials.session);
|
request = request.header("Authorization", &credentials.session);
|
||||||
}
|
}
|
||||||
if let Some(range) = byte_range_header_value(range_start, range_end) {
|
|
||||||
request = request
|
|
||||||
.header(header::RANGE, range)
|
|
||||||
.header(header::ACCEPT_ENCODING, "identity");
|
|
||||||
}
|
|
||||||
let response = match request.send().await {
|
let response = match request.send().await {
|
||||||
Ok(response) => response,
|
Ok(response) => response,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@ -5522,6 +5572,13 @@ async fn download_to_path_inner(
|
|||||||
if routes.is_empty() {
|
if routes.is_empty() {
|
||||||
routes.push(official_route(&request.url, request.resource));
|
routes.push(official_route(&request.url, request.resource));
|
||||||
}
|
}
|
||||||
|
// A local/system proxy can successfully return many small authenticated
|
||||||
|
// objects and then truncate a later response body. Add a same-origin direct
|
||||||
|
// transport and let observed route health choose which transport goes
|
||||||
|
// first. Sensitive headers remain constrained to the original origin by
|
||||||
|
// send_path_request_with_clients.
|
||||||
|
add_starlight_direct_recovery_route(&mut routes);
|
||||||
|
order_auto_routes(&mut routes, request.resource, false);
|
||||||
let part_path = suffixed_path(destination, ".part");
|
let part_path = suffixed_path(destination, ".part");
|
||||||
|
|
||||||
if !request.integrity.is_empty()
|
if !request.integrity.is_empty()
|
||||||
@ -6427,7 +6484,7 @@ async fn download_to_path_inner(
|
|||||||
let mut alternate_probe: Option<RouteProbeFuture<'_>> = None;
|
let mut alternate_probe: Option<RouteProbeFuture<'_>> = None;
|
||||||
let mut alternate_probe_finished = false;
|
let mut alternate_probe_finished = false;
|
||||||
let mut confirmed_switch = None;
|
let mut confirmed_switch = None;
|
||||||
let mut transfer_error: Option<crate::Error> = None;
|
let mut transfer_error: Option<(crate::Error, bool)> = None;
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
item = stream.next() => {
|
item = stream.next() => {
|
||||||
@ -6437,13 +6494,15 @@ async fn download_to_path_inner(
|
|||||||
let chunk = match item {
|
let chunk = match item {
|
||||||
Ok(chunk) => chunk,
|
Ok(chunk) => chunk,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
let decode_failure = error.is_decode();
|
||||||
if is_h2_protocol_failure(&error)
|
if is_h2_protocol_failure(&error)
|
||||||
&& let Some(authority) =
|
&& let Some(authority) =
|
||||||
url_authority(&final_url)
|
url_authority(&final_url)
|
||||||
{
|
{
|
||||||
record_authority_h2_failure(&authority);
|
record_authority_h2_failure(&authority);
|
||||||
}
|
}
|
||||||
transfer_error = Some(error.into());
|
transfer_error =
|
||||||
|
Some((error.into(), decode_failure));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -6519,9 +6578,14 @@ async fn download_to_path_inner(
|
|||||||
idle_ms = elapsed.as_millis(),
|
idle_ms = elapsed.as_millis(),
|
||||||
"Download body idle deadline exceeded"
|
"Download body idle deadline exceeded"
|
||||||
);
|
);
|
||||||
transfer_error = Some(crate::ErrorKind::NetworkError(
|
transfer_error = Some((
|
||||||
format!("download body idle for {}", elapsed.as_secs()),
|
crate::ErrorKind::NetworkError(format!(
|
||||||
).into());
|
"download body idle for {}",
|
||||||
|
elapsed.as_secs()
|
||||||
|
))
|
||||||
|
.into(),
|
||||||
|
false,
|
||||||
|
));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if matches!(
|
if matches!(
|
||||||
@ -6580,7 +6644,7 @@ async fn download_to_path_inner(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(error) = transfer_error {
|
if let Some((error, decode_failure)) = transfer_error {
|
||||||
record_route_failure(route, request.resource, None);
|
record_route_failure(route, request.resource, None);
|
||||||
record_native_transfer_failure(route, None);
|
record_native_transfer_failure(route, None);
|
||||||
preserve_or_remove_partial(
|
preserve_or_remove_partial(
|
||||||
@ -6589,12 +6653,22 @@ async fn download_to_path_inner(
|
|||||||
any_route_can_resume(&routes),
|
any_route_can_resume(&routes),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
if decode_failure && attempts < file_attempt_budget {
|
||||||
|
busted_for_route = Some((
|
||||||
|
route_index,
|
||||||
|
cache_busted_download_url(&route.url, attempts),
|
||||||
|
));
|
||||||
|
}
|
||||||
record_download_attempt_failure(
|
record_download_attempt_failure(
|
||||||
&mut attempt_history,
|
&mut attempt_history,
|
||||||
route,
|
route,
|
||||||
attempts,
|
attempts,
|
||||||
&error,
|
&error,
|
||||||
"resume_or_switch",
|
if decode_failure {
|
||||||
|
"cache_bust_and_resume_or_switch"
|
||||||
|
} else {
|
||||||
|
"resume_or_switch"
|
||||||
|
},
|
||||||
Some(status),
|
Some(status),
|
||||||
remote_addr,
|
remote_addr,
|
||||||
Some(http_version),
|
Some(http_version),
|
||||||
@ -8588,6 +8662,96 @@ mod tests {
|
|||||||
assert_eq!(redirected, original);
|
assert_eq!(redirected, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_requests_disable_content_encoding_from_the_first_attempt() {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let full = apply_file_transport_headers(
|
||||||
|
client.get("https://example.com/file.jar"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
full.headers().get(header::ACCEPT_ENCODING).unwrap(),
|
||||||
|
"identity"
|
||||||
|
);
|
||||||
|
assert!(!full.headers().contains_key(header::RANGE));
|
||||||
|
|
||||||
|
let resumed = apply_file_transport_headers(
|
||||||
|
client.get("https://example.com/file.jar"),
|
||||||
|
Some(1024),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resumed.headers().get(header::ACCEPT_ENCODING).unwrap(),
|
||||||
|
"identity"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resumed.headers().get(header::RANGE).unwrap(),
|
||||||
|
"bytes=1024-"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn starlight_hosted_files_have_a_direct_recovery_route() {
|
||||||
|
let mut routes = vec![official_route(
|
||||||
|
"https://skin.starlight.cool/starlight/mod/packs/files/release/hash",
|
||||||
|
ResourceClass::Modpack,
|
||||||
|
)];
|
||||||
|
|
||||||
|
add_starlight_direct_recovery_route(&mut routes);
|
||||||
|
|
||||||
|
assert_eq!(routes.len(), 2);
|
||||||
|
assert_eq!(routes[0].proxy, ProxyPolicy::System);
|
||||||
|
assert_eq!(routes[1].proxy, ProxyPolicy::Direct);
|
||||||
|
assert_eq!(routes[0].url, routes[1].url);
|
||||||
|
assert!(routes[1].allow_sensitive_headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unrelated_downloads_do_not_bypass_the_configured_proxy() {
|
||||||
|
let mut routes = vec![official_route(
|
||||||
|
"https://libraries.minecraft.net/example.jar",
|
||||||
|
ResourceClass::MinecraftLibrary,
|
||||||
|
)];
|
||||||
|
|
||||||
|
add_starlight_direct_recovery_route(&mut routes);
|
||||||
|
|
||||||
|
assert_eq!(routes.len(), 1);
|
||||||
|
assert_eq!(routes[0].proxy, ProxyPolicy::System);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn file_client_ignores_a_stale_content_encoding_header() {
|
||||||
|
let body = b"raw jar bytes".to_vec();
|
||||||
|
let (url, requests, server) = spawn_http_fixture(
|
||||||
|
"200 OK",
|
||||||
|
"Content-Encoding: gzip\r\n",
|
||||||
|
body.clone(),
|
||||||
|
Duration::ZERO,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let route = direct_test_route(url, DownloadRouteSource::Official);
|
||||||
|
let client = file_reqwest_client_builder()
|
||||||
|
.no_proxy()
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (response, _) = send_path_request_with_clients(
|
||||||
|
&route, None, None, None, None, &client, &client, None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.bytes().await.unwrap().as_ref(), body.as_slice());
|
||||||
|
assert_eq!(requests.load(Ordering::Relaxed), 1);
|
||||||
|
server.abort();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn verifies_streaming_integrity_algorithms() {
|
async fn verifies_streaming_integrity_algorithms() {
|
||||||
let file = tempfile::NamedTempFile::new().unwrap();
|
let file = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user