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

This commit is contained in:
Disy committed 2026-10-20 21:25:19 +08:00
1 parent 1838afce56
commit bf95d715a4
17 files changed
+848 -72

No files matched your search

@@ -0,0 +1,96 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
hostedDownloadActions,
hostedDownloadState,
runHostedDownloadAction,
runHostedDownloadControl,
} from './hosted-download-controls.ts'
test('only a controllable parent hosted task offers pause, resume and stop', () => {
const bar = {
bar_type: {
type: 'hosted_pack_sync',
instance_id: 'one',
state: 'running' as const,
can_pause: true,
can_cancel: true,
},
}
assert.deepEqual(hostedDownloadActions(bar), ['pause', 'cancel'])
assert.deepEqual(hostedDownloadActions({ bar_type: { ...bar.bar_type, state: 'paused' } }), [
'resume',
'cancel',
])
for (const state of ['finalizing', 'canceling'] as const) {
assert.deepEqual(hostedDownloadActions({ bar_type: { ...bar.bar_type, state } }), [])
}
assert.deepEqual(
hostedDownloadActions({ bar_type: { ...bar.bar_type, type: 'hosted_mod_download' } }),
[],
)
assert.deepEqual(
hostedDownloadActions({ bar_type: { type: 'hosted_pack_sync', instance_id: 'one' } }),
[],
)
})
test('failed and canceled hosted tasks offer direct retry, preserving terminal state', () => {
for (const state of ['failed', 'canceled'] as const) {
const bar = {
bar_type: { type: 'hosted_pack_sync', instance_id: 'one', state, error: 'failure' },
}
assert.deepEqual(hostedDownloadActions(bar), ['retry'])
assert.equal(hostedDownloadState(bar), state)
}
assert.equal(hostedDownloadState({ bar_type: { error: 'old backend failure' } }), 'failed')
})
test('resume refreshes the session before invoking controls; pause and stop need no session', async () => {
const calls: string[] = []
const deps = {
prepareSession: async () => {
calls.push('session')
},
invoke: async (command: string, args: { instanceId: string }) => {
calls.push(`${command}:${args.instanceId}`)
},
}
await runHostedDownloadControl('resume', 'one', deps)
await runHostedDownloadControl('pause', 'one', deps)
await runHostedDownloadControl('cancel', 'one', deps)
assert.deepEqual(calls, [
'session',
'plugin:install|hosted_resume:one',
'plugin:install|hosted_pause:one',
'plugin:install|hosted_cancel:one',
])
})
test('session refresh failures prevent resume', async () => {
await assert.rejects(
runHostedDownloadControl('resume', 'one', {
prepareSession: async () => {
throw new Error('signed out')
},
invoke: async () => {
assert.fail('resume must not run')
},
}),
/signed out/,
)
})
test('retry immediately restarts the existing hosted instance through the manager', async () => {
const calls: string[] = []
await runHostedDownloadAction('retry', 'existing-instance', {
retryHosted: async (instanceId) => {
calls.push(instanceId)
},
control: async () => {
assert.fail('retry must use the full hosted transaction')
},
})
assert.deepEqual(calls, ['existing-instance'])
})
@@ -0,0 +1,45 @@
import type { LoadingBar } from './state.ts'
export type HostedDownloadControl = 'pause' | 'resume' | 'cancel'
export type HostedDownloadAction = HostedDownloadControl | 'retry'
export function runHostedDownloadAction(
action: HostedDownloadAction,
instanceId: string,
operations: {
retryHosted: (instanceId: string) => Promise<void>
control: (action: HostedDownloadControl, instanceId: string) => Promise<void>
},
) {
return action === 'retry'
? operations.retryHosted(instanceId)
: operations.control(action, instanceId)
}
export function hostedDownloadState(bar: LoadingBar) {
return bar.bar_type?.state ?? (bar.bar_type?.error ? 'failed' : 'running')
}
export function hostedDownloadActions(bar: LoadingBar): HostedDownloadAction[] {
const type = bar.bar_type
if (type?.type !== 'hosted_pack_sync' || !type.instance_id) return []
const state = hostedDownloadState(bar)
if (state === 'failed' || state === 'canceled') return ['retry']
if (state !== 'running' && state !== 'paused') return []
const actions: HostedDownloadAction[] = []
if (type.can_pause) actions.push(state === 'paused' ? 'resume' : 'pause')
if (type.can_cancel) actions.push('cancel')
return actions
}
export async function runHostedDownloadControl(
action: HostedDownloadControl,
instanceId: string,
dependencies: {
prepareSession: () => Promise<void>
invoke: (command: string, args: { instanceId: string }) => Promise<void>
},
) {
if (action === 'resume') await dependencies.prepareSession()
await dependencies.invoke(`plugin:install|hosted_${action}`, { instanceId })
}
@@ -3,6 +3,27 @@ import test from 'node:test'
import { createHostedDownloadFailures } from './hosted-download-failures.ts' import { createHostedDownloadFailures } from './hosted-download-failures.ts'
test('stopped hosted downloads retain their state and instance for direct retry', () => {
const state = createHostedDownloadFailures()
state.update({
fraction: null,
loader_uuid: 'stopped',
event: {
type: 'hosted_pack_sync',
instance_id: 'existing-instance',
state: 'canceled',
error: 'Stopped by user',
can_pause: false,
can_cancel: false,
},
})
assert.equal(state.values()[0]?.bar_type?.state, 'canceled')
assert.equal(state.values()[0]?.bar_type?.instance_id, 'existing-instance')
state.begin('existing-instance', state.values())
assert.equal(state.has('existing-instance'), false)
assert.equal(state.isRetired('stopped'), true)
})
test('retry clears only its own failure before progress arrives and ignores late old events', () => { test('retry clears only its own failure before progress arrives and ignores late old events', () => {
const state = createHostedDownloadFailures() const state = createHostedDownloadFailures()
const failed = (instanceId: string, loader: string, error: string) => ({ const failed = (instanceId: string, loader: string, error: string) => ({
@@ -8,6 +8,7 @@ import {
skinSiteUser, skinSiteUser,
waitForSkinSiteSession, waitForSkinSiteSession,
} from '../composables/skin-site-session.ts' } from '../composables/skin-site-session.ts'
import { type HostedDownloadControl, runHostedDownloadControl } from './hosted-download-controls.ts'
import { authenticateInstancePlayer } from './instance-player.ts' import { authenticateInstancePlayer } from './instance-player.ts'
export const HOSTED_ACCOUNT_REQUIRED_MESSAGE = '您需要登录正版或皮肤站账号后才能操作' export const HOSTED_ACCOUNT_REQUIRED_MESSAGE = '您需要登录正版或皮肤站账号后才能操作'
@@ -146,6 +147,11 @@ export const hostedCreate = async (gameDirRoot?: string | null) => {
} }
export const hostedBinding = (instanceId: string) => export const hostedBinding = (instanceId: string) =>
invokeHosted<HostedBinding | null>('plugin:install|hosted_binding', { instanceId }) invokeHosted<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
export const controlHostedDownload = (action: HostedDownloadControl, instanceId: string) =>
runHostedDownloadControl(action, instanceId, {
prepareSession: prepareHostedSession,
invoke: invokeHosted<void>,
})
export const hostedSync = async (instanceId: string) => { export const hostedSync = async (instanceId: string) => {
await requireHostedGameAccount() await requireHostedGameAccount()
startHostedPackAttempt(instanceId) startHostedPackAttempt(instanceId)
+9 -1
View File
@@ -9,6 +9,9 @@ export interface LoadingBarType {
batch_id?: string batch_id?: string
file_name?: string file_name?: string
error?: string | null error?: string | null
state?: 'running' | 'paused' | 'canceling' | 'canceled' | 'failed' | 'finalizing'
can_pause?: boolean
can_cancel?: boolean
type?: string type?: string
version?: string version?: string
instance_id?: string instance_id?: string
@@ -28,7 +31,12 @@ export interface LoadingBar {
} }
export type OpeningCommandEvent = export type OpeningCommandEvent =
'RunMRPack' | 'InstallServer' | 'InstallVersion' | 'InstallMod' | 'InstallModpack' | string | 'RunMRPack'
| 'InstallServer'
| 'InstallVersion'
| 'InstallMod'
| 'InstallModpack'
| string
export interface OpeningCommand { export interface OpeningCommand {
event: OpeningCommandEvent event: OpeningCommandEvent
@@ -1,4 +1,8 @@
{ {
"app.hosted-packs.progress.pause": { "message": "Pause" },
"app.hosted-packs.progress.resume": { "message": "Resume" },
"app.hosted-packs.progress.stop": { "message": "Stop" },
"app.downloads.status.paused": { "message": "Paused" },
"app.skin-site.captcha.loading": { "message": "Loading captcha…" }, "app.skin-site.captcha.loading": { "message": "Loading captcha…" },
"app.skin-site.captcha.submit": { "message": "Submit" }, "app.skin-site.captcha.submit": { "message": "Submit" },
"app.skin-site.captcha.subtitle": { "app.skin-site.captcha.subtitle": {
@@ -1,4 +1,8 @@
{ {
"app.hosted-packs.progress.pause": { "message": "暂停" },
"app.hosted-packs.progress.resume": { "message": "继续" },
"app.hosted-packs.progress.stop": { "message": "停止" },
"app.downloads.status.paused": { "message": "已暂停" },
"app.skin-site.captcha.loading": { "message": "正在加载验证码…" }, "app.skin-site.captcha.loading": { "message": "正在加载验证码…" },
"app.skin-site.captcha.submit": { "message": "验证" }, "app.skin-site.captcha.submit": { "message": "验证" },
"app.skin-site.captcha.subtitle": { "app.skin-site.captcha.subtitle": {
+66 -18
View File
@@ -84,7 +84,7 @@
</TagItem> </TagItem>
<Badge <Badge
:color="bar.bar_type?.error ? 'red' : 'orange'" :color="bar.bar_type?.error ? 'red' : 'orange'"
:type="statusLabel(bar.bar_type?.error ? 'failed' : 'running')" :type="statusLabel(hostedDownloadState(bar))"
/> />
</div> </div>
<ProgressBar <ProgressBar
@@ -94,16 +94,35 @@
:progress="legacyPercent(bar)" :progress="legacyPercent(bar)"
:max="100" :max="100"
:label="formatMessage(messages.progress)" :label="formatMessage(messages.progress)"
:waiting="!bar.total" :waiting="!bar.total && hostedDownloadState(bar) !== 'paused'"
:show-progress="Boolean(bar.total)" :show-progress="Boolean(bar.total)"
/> />
<RouterLink <div
v-else v-if="hostedDownloadActions(bar).length"
:to="hostedRetryRoute(bar.bar_type?.instance_id ?? '')" class="mt-3 flex flex-wrap items-center gap-2"
class="mt-3 inline-block text-brand hover:underline"
> >
{{ formatMessage(messages.hostedRetry) }} <ButtonStyled
</RouterLink> v-for="action in hostedDownloadActions(bar)"
:key="action"
:color="action === 'retry' ? 'brand' : action === 'cancel' ? 'red' : 'standard'"
:type="action === 'retry' ? 'standard' : 'outlined'"
size="small"
>
<button
:disabled="busy.has(hostedBusyKey(bar, action))"
@click="hostedAction(bar, action)"
>
{{ formatMessage(hostedActionMessages[action]) }}
</button>
</ButtonStyled>
<RouterLink
v-if="bar.bar_type?.error"
:to="hostedRetryRoute(bar.bar_type.instance_id ?? '')"
class="text-brand hover:underline"
>
{{ formatMessage(messages.openInstance) }}
</RouterLink>
</div>
</Card> </Card>
<Card <Card
@@ -471,7 +490,14 @@ import { useRoute, useRouter } from 'vue-router'
import MissingModpackContentModal from '@/components/ui/modal/MissingModpackContentModal.vue' import MissingModpackContentModal from '@/components/ui/modal/MissingModpackContentModal.vue'
import { listPendingCurseForgeManualDownloads } from '@/helpers/curseforge' import { listPendingCurseForgeManualDownloads } from '@/helpers/curseforge'
import type { CurseForgeManualDownloadItem } from '@/helpers/curseforge-manual' import type { CurseForgeManualDownloadItem } from '@/helpers/curseforge-manual'
import {
type HostedDownloadAction,
hostedDownloadActions,
hostedDownloadState,
runHostedDownloadAction,
} from '@/helpers/hosted-download-controls'
import { hostedRetryRoute } from '@/helpers/hosted-install-retry' import { hostedRetryRoute } from '@/helpers/hosted-install-retry'
import { controlHostedDownload } from '@/helpers/hosted-packs'
import { import {
download_job_support_details, download_job_support_details,
type InstallJobSnapshot, type InstallJobSnapshot,
@@ -517,10 +543,6 @@ const focusedJobId = computed(() => focusedDownloadJobId(route.query.job))
const focusState = ref(createDownloadFocusState(focusedJobId.value)) const focusState = ref(createDownloadFocusState(focusedJobId.value))
const messages = defineMessages({ const messages = defineMessages({
hostedRetry: {
id: 'app.hosted-packs.progress.retry',
defaultMessage: 'Return to the instance to retry',
},
newDownload: { id: 'app.downloads.new-download', defaultMessage: 'New download' }, newDownload: { id: 'app.downloads.new-download', defaultMessage: 'New download' },
inProgress: { id: 'app.downloads.in-progress', defaultMessage: 'In progress' }, inProgress: { id: 'app.downloads.in-progress', defaultMessage: 'In progress' },
history: { id: 'app.downloads.history', defaultMessage: 'History' }, history: { id: 'app.downloads.history', defaultMessage: 'History' },
@@ -653,7 +675,15 @@ const messages = defineMessages({
unknownPhase: { id: 'app.downloads.phase.unknown', defaultMessage: 'Working' }, unknownPhase: { id: 'app.downloads.phase.unknown', defaultMessage: 'Working' },
}) })
const hostedActionMessages = defineMessages({
pause: { id: 'app.hosted-packs.progress.pause', defaultMessage: 'Pause' },
resume: { id: 'app.hosted-packs.progress.resume', defaultMessage: 'Resume' },
cancel: { id: 'app.hosted-packs.progress.stop', defaultMessage: 'Stop' },
retry: { id: 'app.downloads.retry', defaultMessage: 'Retry' },
})
const statusMessages = defineMessages({ const statusMessages = defineMessages({
paused: { id: 'app.downloads.status.paused', defaultMessage: 'Paused' },
queued: { id: 'app.downloads.status.queued', defaultMessage: 'Queued' }, queued: { id: 'app.downloads.status.queued', defaultMessage: 'Queued' },
running: { id: 'app.downloads.status.running', defaultMessage: 'Running' }, running: { id: 'app.downloads.status.running', defaultMessage: 'Running' },
canceling: { id: 'app.downloads.status.canceling', defaultMessage: 'Canceling' }, canceling: { id: 'app.downloads.status.canceling', defaultMessage: 'Canceling' },
@@ -1262,6 +1292,7 @@ function toggleExpanded(jobId: string) {
} }
async function withBusy(jobId: string, action: () => Promise<void>) { async function withBusy(jobId: string, action: () => Promise<void>) {
if (busy.value.has(jobId)) return
busy.value = new Set([...busy.value, jobId]) busy.value = new Set([...busy.value, jobId])
try { try {
await action() await action()
@@ -1278,6 +1309,21 @@ async function cancel(job: InstallJobSnapshot) {
await withBusy(job.job_id, () => manager.cancel(job.job_id)) await withBusy(job.job_id, () => manager.cancel(job.job_id))
} }
function hostedBusyKey(bar: LoadingBar, action: HostedDownloadAction) {
return `hosted-${action === 'retry' ? 'retry' : 'control'}:${bar.bar_type?.instance_id}`
}
async function hostedAction(bar: LoadingBar, action: HostedDownloadAction) {
const instanceId = bar.bar_type?.instance_id
if (!instanceId || !hostedDownloadActions(bar).includes(action)) return
await withBusy(hostedBusyKey(bar, action), () =>
runHostedDownloadAction(action, instanceId, {
retryHosted: manager.retryHosted,
control: controlHostedDownload,
}),
)
}
async function retry(job: InstallJobSnapshot) { async function retry(job: InstallJobSnapshot) {
await withBusy(job.job_id, () => manager.retry(job.job_id)) await withBusy(job.job_id, () => manager.retry(job.job_id))
} }
@@ -1293,12 +1339,14 @@ async function resolveMissing(job: InstallJobSnapshot) {
(item) => (item) =>
item.status === 'skipped' && item.manual_url && item.project_id && item.version_id, item.status === 'skipped' && item.manual_url && item.project_id && item.version_id,
) )
.map((item): CurseForgeManualDownloadItem => ({ .map(
projectId: Number(item.project_id), (item): CurseForgeManualDownloadItem => ({
fileId: Number(item.version_id), projectId: Number(item.project_id),
fileName: item.name, fileId: Number(item.version_id),
websiteUrl: item.manual_url ?? undefined, fileName: item.name,
})) websiteUrl: item.manual_url ?? undefined,
}),
)
const instanceId = job.instance_id const instanceId = job.instance_id
const hasGeneralMissingItems = job.items.some((item) => item.status === 'failed') const hasGeneralMissingItems = job.items.some((item) => item.status === 'failed')
if (instanceId && (job.provider === 'curse_forge' || fallbackCurseForgeItems.length > 0)) { if (instanceId && (job.provider === 'curse_forge' || fallbackCurseForgeItems.length > 0)) {
+3
View File
@@ -380,6 +380,9 @@ fn main() {
"hosted_create", "hosted_create",
"hosted_binding", "hosted_binding",
"hosted_sync", "hosted_sync",
"hosted_pause",
"hosted_resume",
"hosted_cancel",
"hosted_instance_mode", "hosted_instance_mode",
"hosted_set_instance_mode", "hosted_set_instance_mode",
"install_pack_to_existing_instance", "install_pack_to_existing_instance",
+18
View File
@@ -20,6 +20,9 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
hosted_create, hosted_create,
hosted_binding, hosted_binding,
hosted_sync, hosted_sync,
hosted_pause,
hosted_resume,
hosted_cancel,
hosted_instance_mode, hosted_instance_mode,
hosted_set_instance_mode, hosted_set_instance_mode,
install_get_modpack_preview, install_get_modpack_preview,
@@ -105,6 +108,21 @@ pub async fn hosted_sync(
Ok(theseus::pack::hosted::synchronize(&instance_id).await?) Ok(theseus::pack::hosted::synchronize(&instance_id).await?)
} }
#[tauri::command]
pub async fn hosted_pause(instance_id: String) -> Result<()> {
Ok(theseus::pack::hosted::pause(&instance_id).await?)
}
#[tauri::command]
pub async fn hosted_resume(instance_id: String) -> Result<()> {
Ok(theseus::pack::hosted::resume(&instance_id).await?)
}
#[tauri::command]
pub async fn hosted_cancel(instance_id: String) -> Result<()> {
Ok(theseus::pack::hosted::cancel(&instance_id).await?)
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct InstallCreateInstanceRequest { pub struct InstallCreateInstanceRequest {
+21 -31
View File
@@ -1,5 +1,7 @@
//! Administrator-approved skin-site packs. Only content-addressed changed files cross the network. //! Administrator-approved skin-site packs. Only content-addressed changed files cross the network.
mod control;
mod progress; mod progress;
pub use control::{HostedTaskState, cancel, pause, resume};
mod tagged; mod tagged;
mod transport; mod transport;
use crate::{ use crate::{
@@ -938,10 +940,20 @@ async fn synchronize_locked(instance_id: &str) -> crate::Result<SyncResult> {
let metadata = crate::instance::get(instance_id) let metadata = crate::instance::get(instance_id)
.await? .await?
.ok_or_else(|| invalid("Unknown instance"))?; .ok_or_else(|| invalid("Unknown instance"))?;
let task = control::Registration::new(instance_id)?;
let progress = let progress =
PackProgress::new(instance_id, &metadata.instance.name).await?; PackProgress::new(instance_id, &metadata.instance.name).await?;
let result = let result = task
synchronize_with_progress(instance_id, metadata, &progress).await; .control
.run(|| {
synchronize_with_progress(
instance_id,
metadata.clone(),
&progress,
&task.control,
)
})
.await;
if let Err(error) = &result { if let Err(error) = &result {
progress.fail(error); progress.fail(error);
} }
@@ -952,6 +964,7 @@ async fn synchronize_with_progress(
instance_id: &str, instance_id: &str,
metadata: crate::state::InstanceMetadata, metadata: crate::state::InstanceMetadata,
progress: &PackProgress, progress: &PackProgress,
control: &control::Control,
) -> crate::Result<SyncResult> { ) -> crate::Result<SyncResult> {
if metadata.instance.linked_launcher.is_some() if metadata.instance.linked_launcher.is_some()
|| metadata.instance.symlink_target.is_some() || metadata.instance.symlink_target.is_some()
@@ -1275,7 +1288,7 @@ async fn synchronize_with_progress(
&format!("文件 0/{total_files}"), &format!("文件 0/{total_files}"),
true, true,
); );
let results = stream::iter(pending_downloads.into_values().map( downloaded += control::collect_downloads(stream::iter(pending_downloads.into_values().map(
|(file, url, object)| { |(file, url, object)| {
let current = &current; let current = &current;
let completed = &completed; let completed = &completed;
@@ -1342,34 +1355,14 @@ async fn synchronize_with_progress(
request = request request = request
.with_header("Authorization", auth.clone()); .with_header("Authorization", auth.clone());
} }
let first_result = download_to_path( download_to_path(
request.clone(), request.clone(),
&object, &object,
&state.fetch_semaphore, &state.fetch_semaphore,
&state.pool, &state.pool,
Some(&mut on_progress), Some(&mut on_progress),
) )
.await; .await.map_err(|error| error.with_context(format!("下载整合包文件失败:{file_path}")))?;
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?;
}
let completed_files = let completed_files =
completed.fetch_add(1, Ordering::Relaxed) + 1; completed.fetch_add(1, Ordering::Relaxed) + 1;
let completed_bytes = { let completed_bytes = {
@@ -1391,12 +1384,7 @@ async fn synchronize_with_progress(
} }
}, },
)) ))
.buffer_unordered(concurrency) .buffer_unordered(concurrency)).await?;
.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(
@@ -1446,6 +1434,8 @@ async fn synchronize_with_progress(
previous, previous,
metadata, metadata,
}; };
// From this atomic boundary onward the rollback/runtime transaction must finish.
control.begin_finalizing()?;
write_json(&root, JOURNAL, &journal)?; write_json(&root, JOURNAL, &journal)?;
let result = async { let result = async {
progress.update(0, 0, "正在安装游戏组件", true); 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_id: instance_id.to_owned(),
instance_name: name.to_owned(), instance_name: name.to_owned(),
error: None, error: None,
state: super::HostedTaskState::Running,
can_pause: true,
can_cancel: true,
}, },
1.0, 1.0,
"正在获取整合包信息", "正在获取整合包信息",
@@ -681,7 +681,7 @@ pub(super) async fn download(
set_loading(&bar, 0, file.size, "等待下载")?; set_loading(&bar, 0, file.size, "等待下载")?;
work.push((file, url, object, bar)); work.push((file, url, object, bar));
} }
let results = control::collect_downloads(
stream::iter(work.into_iter().map(|(file, url, object, bar)| { stream::iter(work.into_iter().map(|(file, url, object, bar)| {
let current = &current; let current = &current;
let state = &state; let state = &state;
@@ -740,10 +740,7 @@ pub(super) async fn download(
result result
} }
})) }))
.buffer_unordered(concurrency) .buffer_unordered(concurrency),
.collect::<Vec<_>>() )
.await; .await
results
.into_iter()
.try_fold(0, |sum, result| Ok(sum + result?))
} }
+79 -2
View File
@@ -232,8 +232,24 @@ pub fn fail_hosted_loading(key: &LoadingBarId, error: &str) {
&& let Some(mut bar) = state.loading_bars.get_mut(&key.0) && let Some(mut bar) = state.loading_bars.get_mut(&key.0)
{ {
match &mut bar.bar_type { match &mut bar.bar_type {
LoadingBarType::HostedPackSync { error: failure, .. } LoadingBarType::HostedPackSync {
| LoadingBarType::HostedModDownload { error: failure, .. } => { 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()) *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) // emit_warning(message)
pub async fn emit_warning(message: &str) -> crate::Result<()> { pub async fn emit_warning(message: &str) -> crate::Result<()> {
#[cfg(feature = "tauri")] #[cfg(feature = "tauri")]
+6
View File
@@ -142,6 +142,12 @@ pub enum LoadingBarType {
instance_id: String, instance_id: String,
instance_name: String, instance_name: String,
error: Option<String>, error: Option<String>,
#[serde(default)]
state: crate::api::pack::hosted::HostedTaskState,
#[serde(default)]
can_pause: bool,
#[serde(default)]
can_cancel: bool,
}, },
HostedModDownload { HostedModDownload {
instance_id: String, instance_id: String,
+134 -13
View File
@@ -689,6 +689,46 @@ fn bounded_diagnostic_text(value: impl AsRef<str>, max_chars: usize) -> String {
value.as_ref().chars().take(max_chars).collect() 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: &regex::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 { fn download_error_category(error: &crate::Error) -> &'static str {
match error.raw.as_ref() { match error.raw.as_ref() {
ErrorKind::FetchError(source) => { ErrorKind::FetchError(source) => {
@@ -750,7 +790,16 @@ fn download_error_category(error: &crate::Error) -> &'static str {
fn download_error_detail(error: &crate::Error) -> String { fn download_error_detail(error: &crate::Error) -> String {
match error.raw.as_ref() { match error.raw.as_ref() {
ErrorKind::FetchError(source) => source.status().map_or_else( 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()), |status| format!("HTTP {}", status.as_u16()),
), ),
ErrorKind::LabrinthError(error) => error.status.map_or_else( ErrorKind::LabrinthError(error) => error.status.map_or_else(
@@ -929,8 +978,10 @@ fn add_starlight_direct_recovery_route(routes: &mut Vec<DownloadRoute>) {
.filter(|route| { .filter(|route| {
route.proxy == ProxyPolicy::System route.proxy == ProxyPolicy::System
&& Url::parse(&route.url).ok().is_some_and(|url| { && Url::parse(&route.url).ok().is_some_and(|url| {
url.host_str() == Some("skin.starlight.cool") matches!(
&& url.path().starts_with("/starlight/mod/packs/files/") url.host_str(),
Some("skin.starlight.cool" | "download.starlight.cool")
) && url.path().starts_with("/starlight/mod/packs/files/")
}) })
}) })
.cloned() .cloned()
@@ -6308,7 +6359,9 @@ async fn download_to_path_inner(
source = route.source.as_str(), source = route.source.as_str(),
attempt = attempts, attempt = attempts,
max_attempts = file_attempt_budget, 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" "File download request failed; trying the next source or retry"
); );
last_error = Some(error); last_error = Some(error);
@@ -8857,18 +8910,86 @@ mod tests {
#[test] #[test]
fn starlight_hosted_files_have_a_direct_recovery_route() { 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", "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); #[test]
assert_eq!(routes[0].proxy, ProxyPolicy::System); fn starlight_direct_recovery_excludes_other_paths_and_lookalike_hosts() {
assert_eq!(routes[1].proxy, ProxyPolicy::Direct); for url in [
assert_eq!(routes[0].url, routes[1].url); "https://download.starlight.cool:20443/starlight/mod/packs",
assert!(routes[1].allow_sensitive_headers); "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] #[test]