fix: make hosted downloads controllable and failures diagnosable
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
This commit is contained in:
1 parent
1838afce56
commit
bf95d715a4
17 files changed
+848
-72
No files matched your search
@@ -1,5 +1,7 @@
|
||||
//! Administrator-approved skin-site packs. Only content-addressed changed files cross the network.
|
||||
mod control;
|
||||
mod progress;
|
||||
pub use control::{HostedTaskState, cancel, pause, resume};
|
||||
mod tagged;
|
||||
mod transport;
|
||||
use crate::{
|
||||
@@ -938,10 +940,20 @@ async fn synchronize_locked(instance_id: &str) -> crate::Result<SyncResult> {
|
||||
let metadata = crate::instance::get(instance_id)
|
||||
.await?
|
||||
.ok_or_else(|| invalid("Unknown instance"))?;
|
||||
let task = control::Registration::new(instance_id)?;
|
||||
let progress =
|
||||
PackProgress::new(instance_id, &metadata.instance.name).await?;
|
||||
let result =
|
||||
synchronize_with_progress(instance_id, metadata, &progress).await;
|
||||
let result = task
|
||||
.control
|
||||
.run(|| {
|
||||
synchronize_with_progress(
|
||||
instance_id,
|
||||
metadata.clone(),
|
||||
&progress,
|
||||
&task.control,
|
||||
)
|
||||
})
|
||||
.await;
|
||||
if let Err(error) = &result {
|
||||
progress.fail(error);
|
||||
}
|
||||
@@ -952,6 +964,7 @@ async fn synchronize_with_progress(
|
||||
instance_id: &str,
|
||||
metadata: crate::state::InstanceMetadata,
|
||||
progress: &PackProgress,
|
||||
control: &control::Control,
|
||||
) -> crate::Result<SyncResult> {
|
||||
if metadata.instance.linked_launcher.is_some()
|
||||
|| metadata.instance.symlink_target.is_some()
|
||||
@@ -1275,7 +1288,7 @@ async fn synchronize_with_progress(
|
||||
&format!("文件 0/{total_files}"),
|
||||
true,
|
||||
);
|
||||
let results = stream::iter(pending_downloads.into_values().map(
|
||||
downloaded += control::collect_downloads(stream::iter(pending_downloads.into_values().map(
|
||||
|(file, url, object)| {
|
||||
let current = ¤t;
|
||||
let completed = &completed;
|
||||
@@ -1342,34 +1355,14 @@ async fn synchronize_with_progress(
|
||||
request = request
|
||||
.with_header("Authorization", auth.clone());
|
||||
}
|
||||
let first_result = download_to_path(
|
||||
download_to_path(
|
||||
request.clone(),
|
||||
&object,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
Some(&mut on_progress),
|
||||
)
|
||||
.await;
|
||||
if let Err(error) = first_result {
|
||||
tracing::warn!(
|
||||
path = %file_path,
|
||||
%error,
|
||||
"Parallel StarLight file download failed; retrying after the batch load has eased"
|
||||
);
|
||||
ensure_session(auth).await?;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
750,
|
||||
))
|
||||
.await;
|
||||
download_to_path(
|
||||
request,
|
||||
&object,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
Some(&mut on_progress),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
.await.map_err(|error| error.with_context(format!("下载整合包文件失败:{file_path}")))?;
|
||||
let completed_files =
|
||||
completed.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let completed_bytes = {
|
||||
@@ -1391,12 +1384,7 @@ async fn synchronize_with_progress(
|
||||
}
|
||||
},
|
||||
))
|
||||
.buffer_unordered(concurrency)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
downloaded += results.into_iter().try_fold(0, |sum, result| {
|
||||
Ok::<u64, crate::Error>(sum + result?)
|
||||
})?;
|
||||
.buffer_unordered(concurrency)).await?;
|
||||
}
|
||||
ensure_session(&auth).await?;
|
||||
downloaded += tagged::download(
|
||||
@@ -1446,6 +1434,8 @@ async fn synchronize_with_progress(
|
||||
previous,
|
||||
metadata,
|
||||
};
|
||||
// From this atomic boundary onward the rollback/runtime transaction must finish.
|
||||
control.begin_finalizing()?;
|
||||
write_json(&root, JOURNAL, &journal)?;
|
||||
let result = async {
|
||||
progress.update(0, 0, "正在安装游戏组件", true);
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
use futures::{Stream, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
future::Future,
|
||||
sync::{Arc, LazyLock, Mutex},
|
||||
};
|
||||
use tokio::sync::watch;
|
||||
|
||||
#[derive(
|
||||
Clone, Copy, Debug, Default, Deserialize, Serialize, Hash, PartialEq, Eq,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HostedTaskState {
|
||||
#[default]
|
||||
Running,
|
||||
Paused,
|
||||
Canceling,
|
||||
Canceled,
|
||||
Failed,
|
||||
Finalizing,
|
||||
}
|
||||
|
||||
static CONTROLS: LazyLock<dashmap::DashMap<String, Arc<Control>>> =
|
||||
LazyLock::new(dashmap::DashMap::new);
|
||||
|
||||
pub(super) struct Control {
|
||||
instance_id: String,
|
||||
state: Mutex<HostedTaskState>,
|
||||
changes: watch::Sender<HostedTaskState>,
|
||||
}
|
||||
|
||||
fn stopped() -> crate::Error {
|
||||
super::invalid("已停止整合包下载;已下载的缓存会保留,可重试继续")
|
||||
}
|
||||
|
||||
impl Control {
|
||||
fn new(instance_id: &str) -> Self {
|
||||
Self {
|
||||
instance_id: instance_id.to_owned(),
|
||||
state: Mutex::new(HostedTaskState::Running),
|
||||
changes: watch::channel(HostedTaskState::Running).0,
|
||||
}
|
||||
}
|
||||
|
||||
fn state(&self) -> HostedTaskState {
|
||||
*self.state.lock().unwrap_or_else(|error| error.into_inner())
|
||||
}
|
||||
|
||||
fn transition(
|
||||
&self,
|
||||
allowed: &[HostedTaskState],
|
||||
next: HostedTaskState,
|
||||
) -> crate::Result<()> {
|
||||
let mut state =
|
||||
self.state.lock().unwrap_or_else(|error| error.into_inner());
|
||||
if !allowed.contains(&state) {
|
||||
return Err(super::invalid(
|
||||
"任务状态已变化或正在应用安装文件,当前操作不可用",
|
||||
));
|
||||
}
|
||||
*state = next;
|
||||
// Publish under the transition lock, so a late pause cannot overwrite finalization.
|
||||
crate::event::emit::set_hosted_loading_state(&self.instance_id, next);
|
||||
self.changes.send_replace(next);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&self) -> crate::Result<()> {
|
||||
self.transition(&[HostedTaskState::Running], HostedTaskState::Paused)
|
||||
}
|
||||
|
||||
fn resume(&self) -> crate::Result<()> {
|
||||
self.transition(&[HostedTaskState::Paused], HostedTaskState::Running)
|
||||
}
|
||||
|
||||
fn cancel(&self) -> crate::Result<()> {
|
||||
self.transition(
|
||||
&[HostedTaskState::Running, HostedTaskState::Paused],
|
||||
HostedTaskState::Canceling,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn begin_finalizing(&self) -> crate::Result<()> {
|
||||
self.transition(
|
||||
&[HostedTaskState::Running],
|
||||
HostedTaskState::Finalizing,
|
||||
)
|
||||
}
|
||||
|
||||
/// The operation may be dropped only before begin_finalizing. Restarting
|
||||
/// re-plans from verified cache objects and the downloader's partial files.
|
||||
pub(super) async fn run<T, F, Fut>(
|
||||
&self,
|
||||
mut operation: F,
|
||||
) -> crate::Result<T>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = crate::Result<T>>,
|
||||
{
|
||||
let mut changes = self.changes.subscribe();
|
||||
loop {
|
||||
let state = *changes.borrow_and_update();
|
||||
match state {
|
||||
HostedTaskState::Paused => {
|
||||
changes.changed().await.map_err(|_| stopped())?;
|
||||
continue;
|
||||
}
|
||||
HostedTaskState::Canceling => return Err(stopped()),
|
||||
_ => {}
|
||||
}
|
||||
let work = operation();
|
||||
tokio::pin!(work);
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
changed = changes.changed() => {
|
||||
changed.map_err(|_| stopped())?;
|
||||
match self.state() {
|
||||
HostedTaskState::Paused => break,
|
||||
HostedTaskState::Canceling => return Err(stopped()),
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
result = &mut work => {
|
||||
match self.state() {
|
||||
HostedTaskState::Paused => break,
|
||||
HostedTaskState::Canceling => return Err(stopped()),
|
||||
_ => return result,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Registration {
|
||||
instance_id: String,
|
||||
pub(super) control: Arc<Control>,
|
||||
}
|
||||
|
||||
impl Registration {
|
||||
pub(super) fn new(instance_id: &str) -> crate::Result<Self> {
|
||||
use dashmap::mapref::entry::Entry;
|
||||
let control = Arc::new(Control::new(instance_id));
|
||||
match CONTROLS.entry(instance_id.to_owned()) {
|
||||
Entry::Occupied(_) => {
|
||||
return Err(super::invalid("此实例已有整合包下载任务"));
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(control.clone());
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
instance_id: instance_id.to_owned(),
|
||||
control,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Registration {
|
||||
fn drop(&mut self) {
|
||||
CONTROLS.remove_if(&self.instance_id, |_, value| {
|
||||
Arc::ptr_eq(value, &self.control)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup(instance_id: &str) -> crate::Result<Arc<Control>> {
|
||||
CONTROLS
|
||||
.get(instance_id)
|
||||
.map(|entry| entry.value().clone())
|
||||
.ok_or_else(|| super::invalid("整合包下载任务已结束,请重试"))
|
||||
}
|
||||
|
||||
pub async fn pause(instance_id: &str) -> crate::Result<()> {
|
||||
lookup(instance_id)?.pause()
|
||||
}
|
||||
pub async fn resume(instance_id: &str) -> crate::Result<()> {
|
||||
lookup(instance_id)?.resume()
|
||||
}
|
||||
pub async fn cancel(instance_id: &str) -> crate::Result<()> {
|
||||
lookup(instance_id)?.cancel()
|
||||
}
|
||||
|
||||
/// Stop the batch on its first error so stalled peers cannot hide failure.
|
||||
pub(super) async fn collect_downloads<S>(downloads: S) -> crate::Result<u64>
|
||||
where
|
||||
S: Stream<Item = crate::Result<u64>>,
|
||||
{
|
||||
tokio::pin!(downloads);
|
||||
let mut total = 0;
|
||||
while let Some(result) = downloads.next().await {
|
||||
total += result?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::FutureExt;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use tokio::sync::{Notify, Semaphore};
|
||||
|
||||
struct TransferGuard(Arc<AtomicUsize>, Arc<Notify>);
|
||||
|
||||
impl Drop for TransferGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
self.1.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pause_drops_live_transfers_and_resume_restarts_before_stop() {
|
||||
let control = Arc::new(Control::new("pause-resume-test"));
|
||||
let starts = Arc::new(Semaphore::new(0));
|
||||
let drops = Arc::new(AtomicUsize::new(0));
|
||||
let dropped = Arc::new(Notify::new());
|
||||
let task = {
|
||||
let control = control.clone();
|
||||
let starts = starts.clone();
|
||||
let drops = drops.clone();
|
||||
let dropped = dropped.clone();
|
||||
tokio::spawn(async move {
|
||||
control
|
||||
.run(|| {
|
||||
let guard =
|
||||
TransferGuard(drops.clone(), dropped.clone());
|
||||
starts.add_permits(1);
|
||||
async move {
|
||||
let _guard = guard;
|
||||
std::future::pending::<crate::Result<()>>().await
|
||||
}
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
starts.acquire().await.unwrap().forget();
|
||||
control.pause().unwrap();
|
||||
dropped.notified().await;
|
||||
assert_eq!(drops.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(control.state(), HostedTaskState::Paused);
|
||||
control.resume().unwrap();
|
||||
starts.acquire().await.unwrap().forget();
|
||||
control.cancel().unwrap();
|
||||
let result = task.await.unwrap();
|
||||
assert!(result.unwrap_err().to_string().contains("停止"));
|
||||
assert_eq!(drops.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stopping_an_already_paused_task_never_starts_a_transfer() {
|
||||
let control = Arc::new(Control::new("paused-stop-test"));
|
||||
control.pause().unwrap();
|
||||
let task = {
|
||||
let control = control.clone();
|
||||
tokio::spawn(async move {
|
||||
control
|
||||
.run(|| async {
|
||||
panic!("paused work was polled");
|
||||
Ok::<(), crate::Error>(())
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
control.cancel().unwrap();
|
||||
assert!(task.await.unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finalization_rejects_pause_and_stop() {
|
||||
let control = Control::new("finalization-test");
|
||||
control.begin_finalizing().unwrap();
|
||||
assert_eq!(control.state(), HostedTaskState::Finalizing);
|
||||
assert!(control.pause().is_err());
|
||||
assert!(control.cancel().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_and_finalization_have_only_one_winner() {
|
||||
for _ in 0..32 {
|
||||
let control = Arc::new(Control::new("finalization-race-test"));
|
||||
let barrier = Arc::new(std::sync::Barrier::new(2));
|
||||
let task = {
|
||||
let control = control.clone();
|
||||
let barrier = barrier.clone();
|
||||
std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
control.pause().is_ok()
|
||||
})
|
||||
};
|
||||
barrier.wait();
|
||||
let finalized = control.begin_finalizing().is_ok();
|
||||
assert_ne!(finalized, task.join().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_failure_drops_stalled_peer_downloads() {
|
||||
let drops = Arc::new(AtomicUsize::new(0));
|
||||
let guard = TransferGuard(drops.clone(), Arc::new(Notify::new()));
|
||||
let downloads = futures::stream::iter(vec![
|
||||
async move {
|
||||
let _guard = guard;
|
||||
std::future::pending::<crate::Result<u64>>().await
|
||||
}
|
||||
.boxed(),
|
||||
async { Err(super::super::invalid("failed file")) }.boxed(),
|
||||
])
|
||||
.buffer_unordered(2);
|
||||
let result = collect_downloads(downloads).await;
|
||||
assert!(result.unwrap_err().to_string().contains("failed file"));
|
||||
assert_eq!(drops.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_is_unique_and_released_on_drop() {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let registration = Registration::new(&id).unwrap();
|
||||
assert!(Registration::new(&id).is_err());
|
||||
drop(registration);
|
||||
assert!(Registration::new(&id).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,9 @@ impl PackProgress {
|
||||
instance_id: instance_id.to_owned(),
|
||||
instance_name: name.to_owned(),
|
||||
error: None,
|
||||
state: super::HostedTaskState::Running,
|
||||
can_pause: true,
|
||||
can_cancel: true,
|
||||
},
|
||||
1.0,
|
||||
"正在获取整合包信息",
|
||||
|
||||
@@ -681,7 +681,7 @@ pub(super) async fn download(
|
||||
set_loading(&bar, 0, file.size, "等待下载")?;
|
||||
work.push((file, url, object, bar));
|
||||
}
|
||||
let results =
|
||||
control::collect_downloads(
|
||||
stream::iter(work.into_iter().map(|(file, url, object, bar)| {
|
||||
let current = ¤t;
|
||||
let state = &state;
|
||||
@@ -740,10 +740,7 @@ pub(super) async fn download(
|
||||
result
|
||||
}
|
||||
}))
|
||||
.buffer_unordered(concurrency)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
results
|
||||
.into_iter()
|
||||
.try_fold(0, |sum, result| Ok(sum + result?))
|
||||
.buffer_unordered(concurrency),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -232,8 +232,24 @@ pub fn fail_hosted_loading(key: &LoadingBarId, error: &str) {
|
||||
&& let Some(mut bar) = state.loading_bars.get_mut(&key.0)
|
||||
{
|
||||
match &mut bar.bar_type {
|
||||
LoadingBarType::HostedPackSync { error: failure, .. }
|
||||
| LoadingBarType::HostedModDownload { error: failure, .. } => {
|
||||
LoadingBarType::HostedPackSync {
|
||||
error: failure,
|
||||
state,
|
||||
can_pause,
|
||||
can_cancel,
|
||||
..
|
||||
} => {
|
||||
use crate::api::pack::hosted::HostedTaskState;
|
||||
*state = if *state == HostedTaskState::Canceling {
|
||||
HostedTaskState::Canceled
|
||||
} else {
|
||||
HostedTaskState::Failed
|
||||
};
|
||||
*can_pause = false;
|
||||
*can_cancel = false;
|
||||
*failure = Some(error.to_owned());
|
||||
}
|
||||
LoadingBarType::HostedModDownload { error: failure, .. } => {
|
||||
*failure = Some(error.to_owned())
|
||||
}
|
||||
_ => {}
|
||||
@@ -241,6 +257,67 @@ pub fn fail_hosted_loading(key: &LoadingBarId, error: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the authoritative snapshot and notify observers even while all
|
||||
/// transfer futures are paused and no byte-progress event can be produced.
|
||||
pub fn set_hosted_loading_state(
|
||||
instance_id: &str,
|
||||
next: crate::api::pack::hosted::HostedTaskState,
|
||||
) {
|
||||
use crate::api::pack::hosted::HostedTaskState;
|
||||
let Ok(event_state) = crate::EventState::get() else {
|
||||
return;
|
||||
};
|
||||
for mut bar in event_state.loading_bars.iter_mut() {
|
||||
let LoadingBarType::HostedPackSync {
|
||||
instance_id: id,
|
||||
state,
|
||||
can_pause,
|
||||
can_cancel,
|
||||
..
|
||||
} = &mut bar.bar_type
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if id != instance_id {
|
||||
continue;
|
||||
}
|
||||
*state = next;
|
||||
let controllable =
|
||||
matches!(next, HostedTaskState::Running | HostedTaskState::Paused);
|
||||
*can_pause = controllable;
|
||||
*can_cancel = controllable;
|
||||
match next {
|
||||
HostedTaskState::Paused => {
|
||||
bar.message = "已暂停,点击继续以恢复下载".into()
|
||||
}
|
||||
HostedTaskState::Canceling => {
|
||||
bar.message = "正在停止整合包下载".into()
|
||||
}
|
||||
HostedTaskState::Running => {
|
||||
bar.message = "正在继续整合包下载".into()
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
#[cfg(feature = "tauri")]
|
||||
{
|
||||
let _ = event_state.app.emit(
|
||||
"loading",
|
||||
LoadingPayload {
|
||||
fraction: Some(if bar.total > 0.0 {
|
||||
bar.current / bar.total
|
||||
} else {
|
||||
0.0
|
||||
}),
|
||||
message: bar.message.clone(),
|
||||
event: bar.bar_type.clone(),
|
||||
loader_uuid: bar.loading_bar_uuid,
|
||||
total: Some(bar.total),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// emit_warning(message)
|
||||
pub async fn emit_warning(message: &str) -> crate::Result<()> {
|
||||
#[cfg(feature = "tauri")]
|
||||
|
||||
@@ -142,6 +142,12 @@ pub enum LoadingBarType {
|
||||
instance_id: String,
|
||||
instance_name: String,
|
||||
error: Option<String>,
|
||||
#[serde(default)]
|
||||
state: crate::api::pack::hosted::HostedTaskState,
|
||||
#[serde(default)]
|
||||
can_pause: bool,
|
||||
#[serde(default)]
|
||||
can_cancel: bool,
|
||||
},
|
||||
HostedModDownload {
|
||||
instance_id: String,
|
||||
|
||||
@@ -689,6 +689,46 @@ fn bounded_diagnostic_text(value: impl AsRef<str>, max_chars: usize) -> String {
|
||||
value.as_ref().chars().take(max_chars).collect()
|
||||
}
|
||||
|
||||
fn sanitize_download_error_text(value: &str) -> String {
|
||||
static URLS: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||
regex::Regex::new(r"(?i)\b(?:https?|socks4a?|socks5h?)://[^\s]+")
|
||||
.expect("download diagnostic URL pattern should be valid")
|
||||
});
|
||||
let sanitized = URLS.replace_all(value, |matched: ®ex::Captures<'_>| {
|
||||
let url = &matched[0];
|
||||
// A malformed URL must not fall back to logging its userinfo. The
|
||||
// normal helper also strips signed query strings and fragments.
|
||||
if Url::parse(url).is_ok() {
|
||||
sanitize_url_for_log(url)
|
||||
} else {
|
||||
"[redacted URL]".to_string()
|
||||
}
|
||||
});
|
||||
bounded_diagnostic_text(sanitized, 256)
|
||||
}
|
||||
|
||||
fn download_transport_error_detail(source: &reqwest::Error) -> String {
|
||||
// reqwest's Display stops before the useful DNS/TLS/socket cause and can
|
||||
// include a signed request URL. Walk only its underlying causes instead.
|
||||
let mut details = Vec::new();
|
||||
let mut cause = source.source();
|
||||
while let Some(error) = cause {
|
||||
let detail = sanitize_download_error_text(&error.to_string());
|
||||
if details.last() != Some(&detail) {
|
||||
details.push(detail);
|
||||
}
|
||||
if details.len() == 8 {
|
||||
break;
|
||||
}
|
||||
cause = error.source();
|
||||
}
|
||||
if details.is_empty() {
|
||||
sanitize_download_error_text(&source.to_string())
|
||||
} else {
|
||||
bounded_diagnostic_text(details.join(": "), 256)
|
||||
}
|
||||
}
|
||||
|
||||
fn download_error_category(error: &crate::Error) -> &'static str {
|
||||
match error.raw.as_ref() {
|
||||
ErrorKind::FetchError(source) => {
|
||||
@@ -750,7 +790,16 @@ fn download_error_category(error: &crate::Error) -> &'static str {
|
||||
fn download_error_detail(error: &crate::Error) -> String {
|
||||
match error.raw.as_ref() {
|
||||
ErrorKind::FetchError(source) => source.status().map_or_else(
|
||||
|| format!("{} failure", download_error_category(error)),
|
||||
|| {
|
||||
bounded_diagnostic_text(
|
||||
format!(
|
||||
"{} failure: {}",
|
||||
download_error_category(error),
|
||||
download_transport_error_detail(source)
|
||||
),
|
||||
256,
|
||||
)
|
||||
},
|
||||
|status| format!("HTTP {}", status.as_u16()),
|
||||
),
|
||||
ErrorKind::LabrinthError(error) => error.status.map_or_else(
|
||||
@@ -929,8 +978,10 @@ fn add_starlight_direct_recovery_route(routes: &mut Vec<DownloadRoute>) {
|
||||
.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/")
|
||||
matches!(
|
||||
url.host_str(),
|
||||
Some("skin.starlight.cool" | "download.starlight.cool")
|
||||
) && url.path().starts_with("/starlight/mod/packs/files/")
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
@@ -6308,7 +6359,9 @@ async fn download_to_path_inner(
|
||||
source = route.source.as_str(),
|
||||
attempt = attempts,
|
||||
max_attempts = file_attempt_budget,
|
||||
error = %error,
|
||||
proxy = ?route.proxy,
|
||||
category = download_error_category(&error),
|
||||
error = %download_error_detail(&error),
|
||||
"File download request failed; trying the next source or retry"
|
||||
);
|
||||
last_error = Some(error);
|
||||
@@ -8857,18 +8910,86 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn starlight_hosted_files_have_a_direct_recovery_route() {
|
||||
let mut routes = vec![official_route(
|
||||
for url in [
|
||||
"https://skin.starlight.cool/starlight/mod/packs/files/release/hash",
|
||||
ResourceClass::Modpack,
|
||||
)];
|
||||
"https://download.starlight.cool:20443/starlight/mod/packs/files/4/hash",
|
||||
] {
|
||||
let mut routes = vec![official_route(url, ResourceClass::Modpack)];
|
||||
add_starlight_direct_recovery_route(&mut routes);
|
||||
add_starlight_direct_recovery_route(&mut routes);
|
||||
|
||||
add_starlight_direct_recovery_route(&mut routes);
|
||||
assert_eq!(routes.len(), 2, "{url}");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 starlight_direct_recovery_excludes_other_paths_and_lookalike_hosts() {
|
||||
for url in [
|
||||
"https://download.starlight.cool:20443/starlight/mod/packs",
|
||||
"https://download.starlight.cool.evil.example/starlight/mod/packs/files/4/hash",
|
||||
"https://evil.example/starlight/mod/packs/files/4/hash",
|
||||
"https://skin.starlight.cool/yggdrasil",
|
||||
] {
|
||||
let mut routes = vec![official_route(url, ResourceClass::Modpack)];
|
||||
add_starlight_direct_recovery_route(&mut routes);
|
||||
assert_eq!(routes.len(), 1, "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_transport_diagnostic_preserves_causes_without_url_secrets()
|
||||
{
|
||||
let listener =
|
||||
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
drop(listener);
|
||||
let url = format!(
|
||||
"http://username:password@{address}/file?token=secret#fragment"
|
||||
);
|
||||
let error: crate::Error = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.unwrap()
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.unwrap_err()
|
||||
.into();
|
||||
let detail = download_error_detail(&error);
|
||||
assert_eq!(download_error_category(&error), "connect");
|
||||
assert!(detail.contains("connect"), "{detail}");
|
||||
assert!(
|
||||
detail.contains("tcp") || detail.contains("os error"),
|
||||
"{detail}"
|
||||
);
|
||||
for secret in ["username", "password", "token", "secret", "fragment"] {
|
||||
assert!(!detail.contains(secret), "{detail}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_transport_diagnostic_scrubs_embedded_urls() {
|
||||
let detail = sanitize_download_error_text(
|
||||
"request https://username:password@example.com/file?token=secret#fragment failed: connecting to http://proxyuser:proxypass@proxy.example:8080/?key=proxysecret: connection refused",
|
||||
);
|
||||
assert!(detail.contains("https://example.com/file"), "{detail}");
|
||||
assert!(detail.contains("http://proxy.example:8080/"), "{detail}");
|
||||
assert!(detail.contains("connection refused"), "{detail}");
|
||||
for secret in [
|
||||
"username",
|
||||
"password",
|
||||
"token",
|
||||
"secret",
|
||||
"fragment",
|
||||
"proxyuser",
|
||||
"proxypass",
|
||||
] {
|
||||
assert!(!detail.contains(secret), "{detail}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user