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
@@ -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'
|
||||
|
||||
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', () => {
|
||||
const state = createHostedDownloadFailures()
|
||||
const failed = (instanceId: string, loader: string, error: string) => ({
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
skinSiteUser,
|
||||
waitForSkinSiteSession,
|
||||
} from '../composables/skin-site-session.ts'
|
||||
import { type HostedDownloadControl, runHostedDownloadControl } from './hosted-download-controls.ts'
|
||||
import { authenticateInstancePlayer } from './instance-player.ts'
|
||||
|
||||
export const HOSTED_ACCOUNT_REQUIRED_MESSAGE = '您需要登录正版或皮肤站账号后才能操作'
|
||||
@@ -146,6 +147,11 @@ export const hostedCreate = async (gameDirRoot?: string | null) => {
|
||||
}
|
||||
export const hostedBinding = (instanceId: string) =>
|
||||
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) => {
|
||||
await requireHostedGameAccount()
|
||||
startHostedPackAttempt(instanceId)
|
||||
|
||||
@@ -9,6 +9,9 @@ export interface LoadingBarType {
|
||||
batch_id?: string
|
||||
file_name?: string
|
||||
error?: string | null
|
||||
state?: 'running' | 'paused' | 'canceling' | 'canceled' | 'failed' | 'finalizing'
|
||||
can_pause?: boolean
|
||||
can_cancel?: boolean
|
||||
type?: string
|
||||
version?: string
|
||||
instance_id?: string
|
||||
@@ -28,7 +31,12 @@ export interface LoadingBar {
|
||||
}
|
||||
|
||||
export type OpeningCommandEvent =
|
||||
'RunMRPack' | 'InstallServer' | 'InstallVersion' | 'InstallMod' | 'InstallModpack' | string
|
||||
| 'RunMRPack'
|
||||
| 'InstallServer'
|
||||
| 'InstallVersion'
|
||||
| 'InstallMod'
|
||||
| 'InstallModpack'
|
||||
| string
|
||||
|
||||
export interface OpeningCommand {
|
||||
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.submit": { "message": "Submit" },
|
||||
"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.submit": { "message": "验证" },
|
||||
"app.skin-site.captcha.subtitle": {
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
</TagItem>
|
||||
<Badge
|
||||
:color="bar.bar_type?.error ? 'red' : 'orange'"
|
||||
:type="statusLabel(bar.bar_type?.error ? 'failed' : 'running')"
|
||||
:type="statusLabel(hostedDownloadState(bar))"
|
||||
/>
|
||||
</div>
|
||||
<ProgressBar
|
||||
@@ -94,16 +94,35 @@
|
||||
:progress="legacyPercent(bar)"
|
||||
:max="100"
|
||||
:label="formatMessage(messages.progress)"
|
||||
:waiting="!bar.total"
|
||||
:waiting="!bar.total && hostedDownloadState(bar) !== 'paused'"
|
||||
:show-progress="Boolean(bar.total)"
|
||||
/>
|
||||
<RouterLink
|
||||
v-else
|
||||
:to="hostedRetryRoute(bar.bar_type?.instance_id ?? '')"
|
||||
class="mt-3 inline-block text-brand hover:underline"
|
||||
<div
|
||||
v-if="hostedDownloadActions(bar).length"
|
||||
class="mt-3 flex flex-wrap items-center gap-2"
|
||||
>
|
||||
{{ formatMessage(messages.hostedRetry) }}
|
||||
</RouterLink>
|
||||
<ButtonStyled
|
||||
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
|
||||
@@ -471,7 +490,14 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import MissingModpackContentModal from '@/components/ui/modal/MissingModpackContentModal.vue'
|
||||
import { listPendingCurseForgeManualDownloads } from '@/helpers/curseforge'
|
||||
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 { controlHostedDownload } from '@/helpers/hosted-packs'
|
||||
import {
|
||||
download_job_support_details,
|
||||
type InstallJobSnapshot,
|
||||
@@ -517,10 +543,6 @@ const focusedJobId = computed(() => focusedDownloadJobId(route.query.job))
|
||||
const focusState = ref(createDownloadFocusState(focusedJobId.value))
|
||||
|
||||
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' },
|
||||
inProgress: { id: 'app.downloads.in-progress', defaultMessage: 'In progress' },
|
||||
history: { id: 'app.downloads.history', defaultMessage: 'History' },
|
||||
@@ -653,7 +675,15 @@ const messages = defineMessages({
|
||||
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({
|
||||
paused: { id: 'app.downloads.status.paused', defaultMessage: 'Paused' },
|
||||
queued: { id: 'app.downloads.status.queued', defaultMessage: 'Queued' },
|
||||
running: { id: 'app.downloads.status.running', defaultMessage: 'Running' },
|
||||
canceling: { id: 'app.downloads.status.canceling', defaultMessage: 'Canceling' },
|
||||
@@ -1262,6 +1292,7 @@ function toggleExpanded(jobId: string) {
|
||||
}
|
||||
|
||||
async function withBusy(jobId: string, action: () => Promise<void>) {
|
||||
if (busy.value.has(jobId)) return
|
||||
busy.value = new Set([...busy.value, jobId])
|
||||
try {
|
||||
await action()
|
||||
@@ -1278,6 +1309,21 @@ async function cancel(job: InstallJobSnapshot) {
|
||||
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) {
|
||||
await withBusy(job.job_id, () => manager.retry(job.job_id))
|
||||
}
|
||||
@@ -1293,12 +1339,14 @@ async function resolveMissing(job: InstallJobSnapshot) {
|
||||
(item) =>
|
||||
item.status === 'skipped' && item.manual_url && item.project_id && item.version_id,
|
||||
)
|
||||
.map((item): CurseForgeManualDownloadItem => ({
|
||||
projectId: Number(item.project_id),
|
||||
fileId: Number(item.version_id),
|
||||
fileName: item.name,
|
||||
websiteUrl: item.manual_url ?? undefined,
|
||||
}))
|
||||
.map(
|
||||
(item): CurseForgeManualDownloadItem => ({
|
||||
projectId: Number(item.project_id),
|
||||
fileId: Number(item.version_id),
|
||||
fileName: item.name,
|
||||
websiteUrl: item.manual_url ?? undefined,
|
||||
}),
|
||||
)
|
||||
const instanceId = job.instance_id
|
||||
const hasGeneralMissingItems = job.items.some((item) => item.status === 'failed')
|
||||
if (instanceId && (job.provider === 'curse_forge' || fallbackCurseForgeItems.length > 0)) {
|
||||
|
||||
@@ -380,6 +380,9 @@ fn main() {
|
||||
"hosted_create",
|
||||
"hosted_binding",
|
||||
"hosted_sync",
|
||||
"hosted_pause",
|
||||
"hosted_resume",
|
||||
"hosted_cancel",
|
||||
"hosted_instance_mode",
|
||||
"hosted_set_instance_mode",
|
||||
"install_pack_to_existing_instance",
|
||||
|
||||
@@ -20,6 +20,9 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
hosted_create,
|
||||
hosted_binding,
|
||||
hosted_sync,
|
||||
hosted_pause,
|
||||
hosted_resume,
|
||||
hosted_cancel,
|
||||
hosted_instance_mode,
|
||||
hosted_set_instance_mode,
|
||||
install_get_modpack_preview,
|
||||
@@ -105,6 +108,21 @@ pub async fn hosted_sync(
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstallCreateInstanceRequest {
|
||||
|
||||
@@ -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