feat(dlflow): M105 安装选项区(四家加载器 + 官方版本列表 + 实例名)与取消安装真停
用户实跑反馈两条,本轮各自定位到根因并修掉:
1. 「下载页没有前置加载器选项」= 功能未做:`Message::InstallVersion` 直接派
`AppCommand::InstallVersion`(= 只装原版),core 早在 M32/M35/M45 就有四家加载器的
安装管线。现在选中版本之后面板先给「安装选项」区(原版 / Forge / NeoForge / Fabric /
Quilt + 该加载器的官方版本列表 + 实例名),点「安装」才派
`AppCommand::CreateInstance`。
- 加载器版本列表复用 core 的 `LoaderSource::loader_versions`,经新增的
`AppCommand::ListLoaderVersions` / `CoreEvent::LoaderVersions` 回流;
缓存键是 `(加载器, 原版版本)`,切版本不会串列表;取不到给具名原因并**禁掉确认**。
- 不兼容组合用静态规则在**选中那一刻**说清(NeoForge 需 1.20.1+、Fabric/Quilt 需
1.14+、远古版四家都不支持),不让用户「选完才失败」,也不为不可能的组合作无谓联网。
- 选项草稿(选中 + 加载器 + 加载器版本 + 实例名)住在 `App`,`refresh_view` 带进页面
模型(与分类筛选同一做法;页面自己存不住跨事件的东西)。
2. 「点取消没反应」= 真 bug,两跳各断一半:
- 第一跳(UI 侧):取消确实生效了,但**还在跑的后台线程继续发进度**,`InstallProgress(None)`
刚把面板收回,下一条「下载中」立刻又点亮它 —— 用户看到的就是「点了没反应」。
修法:`CoreHost` 加安装代次 `install_epoch`(开工与取消都 +1),`HostTask::Install` /
`InstallDone` 带代次回来,代次不符一律丢。
- 第二跳(core 侧):`execute` 没有任何取消入参,取消只停在段边界,传输根本没停。
新增 `execute_with_cancel(…, cancel: Option<&AtomicBool>)`(既有 `execute` 原样包装,
CLI 一字未改):取消后不再起新文件,在飞的那个与「取消 future」`select` 赛跑,取消赢就
drop 掉下载 future(响应流关闭、`.ullpart` 由 `PartFile::Drop` 清掉),并且不写版本清单,
返回新的 `InstallErrorKind::Cancelled`。
- 另外:取消后**绝不登记实例**(文件段跑完但用户已取消的那一路也拦住);取消文案把
「哪些留着、哪些一定没发生」说清(D21)。
实测(真网络装置 `host/cancel_tests.rs`,`#[ignore]` 守着,跑法见文件头):
取消前 files_done=2 / `.ullpart` 613 字节;取消后 20 s 进度事件 **0 条**、`.ullpart` **0 字节**
(-613)、版本清单未落盘、实例目录不存在。取消前是 +201466 字节(同一个装置量的)。
文案全部进 i18n(中英,9 条新 key + 金表同步)。
This commit is contained in:
1 parent
d690d09b84
commit
30b1ff6f48
39 files changed
+2550
-518
No files matched your search
@@ -17,160 +17,38 @@
|
||||
//! **不整体回滚**:已下好的共享层文件(libraries/assets)留在原地复用,失败信息逐文件结构化
|
||||
//! 返回,由调用方决定是重试、还是只提示"缺哪几个文件"。这与 analyse/03 §6.3 的"宁可给出
|
||||
//! 可修复的骨架"一致,但**版本清单与实例登记只在全量成功后才写**,不会出现半实例。
|
||||
//!
|
||||
//! # 取消(M105:GUI 的「取消安装」要**真停**)
|
||||
//!
|
||||
//! [`execute`] 与 CLI 的契约**一字不变**(不传取消标志 = 今天的行为);
|
||||
//! 取消走 [`execute_with_cancel`],判据三条:
|
||||
//!
|
||||
//! 1. **不再起新文件**:每个文件开工前看一眼标志,置位就不进 `install_one`;
|
||||
//! 2. **在飞的文件就地掐断**:`install_one` 与「取消 future」`select` 赛跑,
|
||||
//! 取消赢就 drop 掉下载 future —— reqwest 的响应流随之关闭(socket 断开),
|
||||
//! `.ullpart` 由 [`PartFile`](crate::download::PartFile) 的 `Drop` 清掉(不留半截垃圾);
|
||||
//! 3. **半成品不许变成品**:取消后不再写版本 JSON,返回
|
||||
//! [`InstallErrorKind::Cancelled`](super::InstallErrorKind),调用方(presenter)据此把阶段
|
||||
//! 记成「已取消」而不是「失败」。
|
||||
//!
|
||||
//! 于是取消延迟 = **一个数据块的唤醒**(不是「等一个文件下完」,也不是「等这一段跑完」)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::future::poll_fn;
|
||||
use std::path::Path;
|
||||
use std::pin::pin;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::task::Poll;
|
||||
|
||||
use futures_util::future::{select, Either};
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
|
||||
use super::plan::{FileRole, PlanFile};
|
||||
use super::InstallPlan;
|
||||
use super::plan::PlanFile;
|
||||
use super::{InstallError, InstallErrorKind, InstallPlan};
|
||||
use crate::download::verify;
|
||||
use crate::download::{DownloadEngine, DownloadError, DownloadErrorKind, HashAlgo};
|
||||
use crate::platform::atomic::write_atomic;
|
||||
|
||||
/// 单个文件的结局。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FileStatus {
|
||||
/// 本次真的下载(或从缓存直取)并落盘。
|
||||
Downloaded,
|
||||
/// 已存在且校验通过,跳过。
|
||||
Skipped,
|
||||
/// 失败(原因在 [`FileOutcome::failure`])。
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl FileStatus {
|
||||
/// 稳定字符串 id。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Downloaded => "downloaded",
|
||||
Self::Skipped => "skipped",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次失败的分类信息(调用方据此给 `install_network` / `install_integrity` 等稳定码)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileFailure {
|
||||
/// 下载错误分类。
|
||||
pub kind: DownloadErrorKind,
|
||||
/// 人读说明。
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 一个文件的执行结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileOutcome {
|
||||
/// 角色。
|
||||
pub role: FileRole,
|
||||
/// 人读标识。
|
||||
pub name: String,
|
||||
/// 落盘路径。
|
||||
pub dest: std::path::PathBuf,
|
||||
/// 结局。
|
||||
pub status: FileStatus,
|
||||
/// 实际字节数(跳过时为盘上大小)。
|
||||
pub bytes: u64,
|
||||
/// 成功的源标识(`bmclapi` / `official` / `cache` / `metadata`)。
|
||||
pub source: Option<String>,
|
||||
/// 失败详情。
|
||||
pub failure: Option<FileFailure>,
|
||||
}
|
||||
|
||||
impl FileOutcome {
|
||||
/// 是否失败。
|
||||
#[must_use]
|
||||
pub fn failed(&self) -> bool {
|
||||
self.status == FileStatus::Failed
|
||||
}
|
||||
}
|
||||
|
||||
/// 进度回调:每完成一个文件调用一次(可跨线程,由执行器持有 `Arc`)。
|
||||
///
|
||||
/// CLI 用它往 stderr 打实时进度;GUI 侧直接消费 `ProgressEvent` 事件流即可,不必用这个。
|
||||
pub type ProgressSink = Arc<dyn Fn(&FileOutcome) + Send + Sync>;
|
||||
|
||||
/// 一次安装的执行结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InstallReport {
|
||||
/// 逐文件结果(顺序不保证,按角色/大小汇总即可)。
|
||||
pub files: Vec<FileOutcome>,
|
||||
/// 版本清单落盘路径;`None` = 有文件失败,故意没写(见 [`execute`] 的说明)。
|
||||
pub version_json: Option<PathBuf>,
|
||||
/// 计划阶段带来的告警(缺件降级说明)。
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl InstallReport {
|
||||
/// 真正下载的文件数。
|
||||
#[must_use]
|
||||
pub fn downloaded(&self) -> usize {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|file| file.status == FileStatus::Downloaded)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 跳过(已存在且校验通过)的文件数。
|
||||
#[must_use]
|
||||
pub fn skipped(&self) -> usize {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|file| file.status == FileStatus::Skipped)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 失败的文件数。
|
||||
#[must_use]
|
||||
pub fn failed(&self) -> usize {
|
||||
self.files.iter().filter(|file| file.failed()).count()
|
||||
}
|
||||
|
||||
/// 本次写入的字节数(只算真正下载的)。
|
||||
#[must_use]
|
||||
pub fn bytes(&self) -> u64 {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|file| file.status == FileStatus::Downloaded)
|
||||
.map(|file| file.bytes)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// 是否全部就绪(无失败)。
|
||||
#[must_use]
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.failed() == 0
|
||||
}
|
||||
|
||||
/// 失败文件列表(最多取前 `limit` 条,给 CLI 拼提示用)。
|
||||
#[must_use]
|
||||
pub fn failures(&self, limit: usize) -> Vec<&FileOutcome> {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|file| file.failed())
|
||||
.take(limit)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 失败里最"严重"的大类:有校验不符就先报它,其余按网络/IO 归。
|
||||
#[must_use]
|
||||
pub fn failure_kind(&self) -> Option<DownloadErrorKind> {
|
||||
let mut fallback = None;
|
||||
for file in self.files.iter().filter(|file| file.failed()) {
|
||||
let Some(failure) = &file.failure else {
|
||||
continue;
|
||||
};
|
||||
if failure.kind == DownloadErrorKind::Integrity {
|
||||
return Some(DownloadErrorKind::Integrity);
|
||||
}
|
||||
fallback.get_or_insert(failure.kind);
|
||||
}
|
||||
fallback
|
||||
}
|
||||
}
|
||||
pub use super::report::{FileFailure, FileOutcome, FileStatus, InstallReport, ProgressSink};
|
||||
|
||||
/// 执行安装计划。
|
||||
///
|
||||
@@ -191,6 +69,28 @@ pub async fn execute(
|
||||
concurrency: usize,
|
||||
sink: Option<ProgressSink>,
|
||||
) -> Result<InstallReport, super::InstallError> {
|
||||
execute_with_cancel(engine, plan, concurrency, sink, None).await
|
||||
}
|
||||
|
||||
/// 同 [`execute`],但可以带一个**取消标志**(M105)。
|
||||
///
|
||||
/// `cancel = None` 与 [`execute`] 逐字等价(CLI 走这条);`Some(flag)` 时:
|
||||
/// 置位就不再起新文件、并就地掐断在飞的文件,最后返回
|
||||
/// [`InstallErrorKind::Cancelled`](**不写版本 JSON**)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 同 [`execute`];另外取消时返回 `Cancelled` 大类。
|
||||
pub async fn execute_with_cancel(
|
||||
engine: &DownloadEngine,
|
||||
plan: &InstallPlan,
|
||||
concurrency: usize,
|
||||
sink: Option<ProgressSink>,
|
||||
cancel: Option<&AtomicBool>,
|
||||
) -> Result<InstallReport, super::InstallError> {
|
||||
if is_cancelled(cancel) {
|
||||
return Err(cancelled_error());
|
||||
}
|
||||
let mut outcomes: Vec<FileOutcome> = Vec::with_capacity(plan.files.len());
|
||||
let mut prefetched: Vec<&PlanFile> = Vec::new();
|
||||
|
||||
@@ -260,18 +160,38 @@ pub async fn execute(
|
||||
.map(|file| {
|
||||
let sink = sink.clone();
|
||||
async move {
|
||||
let outcome = install_one(engine, file).await;
|
||||
if let Some(sink) = &sink {
|
||||
sink(&outcome);
|
||||
// 取消后不再起新文件;在飞的那个与取消 future 赛跑,取消赢就 drop 掉下载 future
|
||||
// (响应流关闭 + `.ullpart` 由 `PartFile::Drop` 清掉),于是取消延迟 = 一个数据块。
|
||||
let outcome = match cancel {
|
||||
Some(flag) => {
|
||||
let download = install_one(engine, file);
|
||||
let abort = cancelled(flag);
|
||||
let mut download = pin!(download);
|
||||
let mut abort = pin!(abort);
|
||||
match select(download.as_mut(), abort.as_mut()).await {
|
||||
Either::Left((outcome, _)) => Some(outcome),
|
||||
Either::Right(((), _)) => None,
|
||||
}
|
||||
}
|
||||
None => Some(install_one(engine, file).await),
|
||||
};
|
||||
if let (Some(sink), Some(outcome)) = (&sink, &outcome) {
|
||||
sink(outcome);
|
||||
}
|
||||
outcome
|
||||
}
|
||||
})
|
||||
.buffer_unordered(concurrency.max(1))
|
||||
.filter_map(|outcome| async move { outcome })
|
||||
.collect()
|
||||
.await;
|
||||
outcomes.extend(downloaded);
|
||||
|
||||
// 取消了就到此为止:**不写版本清单**(否则半成品会被 `is_version_installed()` 当成已安装)。
|
||||
if is_cancelled(cancel) {
|
||||
return Err(cancelled_error());
|
||||
}
|
||||
|
||||
// 版本清单只在**全量就绪**时落盘:`is_version_installed()` 以清单存在为判据,
|
||||
// 半成品写进去会让 `instances list` 把"缺 jar 的版本"报成已安装。
|
||||
let complete = outcomes.iter().all(|outcome| !outcome.failed());
|
||||
@@ -289,6 +209,38 @@ pub async fn execute(
|
||||
})
|
||||
}
|
||||
|
||||
/// 标志是否已置位(`None` = 没有取消通道,永远 false)。
|
||||
///
|
||||
/// 用 `SeqCst` 与 presenter 侧 `store` 的强度一致:这里不是性能热点(每个文件一次),
|
||||
/// 换更强的顺序只买一个「不给自己留疑问」。
|
||||
fn is_cancelled(cancel: Option<&AtomicBool>) -> bool {
|
||||
cancel.is_some_and(|flag| flag.load(Ordering::SeqCst))
|
||||
}
|
||||
|
||||
/// 取消 future:标志一置位就 `Ready`,否则永远 `Pending`(不注册 waker)。
|
||||
///
|
||||
/// 为什么敢用「不注册 waker 的 Pending」:它只跟下载 future 一起被 `select` 轮询,
|
||||
/// 下载每读到一个数据块就会唤醒 executor、连带重新 poll 这个 future,所以取消延迟
|
||||
/// 天然是「一个数据块」;真正卡死的连接另有读超时兜底(`chunked` 的 30 s)。
|
||||
async fn cancelled(flag: &AtomicBool) {
|
||||
poll_fn(|_| {
|
||||
if flag.load(Ordering::SeqCst) {
|
||||
Poll::Ready(())
|
||||
} else {
|
||||
Poll::Pending
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// 取消的稳定错误(大类 `Cancelled`,不是失败)。
|
||||
fn cancelled_error() -> InstallError {
|
||||
InstallError::new(
|
||||
InstallErrorKind::Cancelled,
|
||||
"已取消:剩下的文件没有开始下载,已下好的文件保留(重装续用)",
|
||||
)
|
||||
}
|
||||
|
||||
/// 单文件:先判"已就绪",否则走引擎下载。
|
||||
async fn install_one(engine: &DownloadEngine, file: &PlanFile) -> FileOutcome {
|
||||
if let Some(bytes) = current_bytes(file) {
|
||||
|
||||
@@ -32,14 +32,16 @@ pub mod instance;
|
||||
pub mod libraries;
|
||||
pub mod plan;
|
||||
pub mod plan_view;
|
||||
mod report;
|
||||
pub mod source;
|
||||
|
||||
use std::fmt;
|
||||
|
||||
pub use assets::{AssetIndex, AssetObject, ASSET_OBJECT_BASE};
|
||||
pub use execute::{execute, FileFailure, FileOutcome, FileStatus, InstallReport, ProgressSink};
|
||||
pub use execute::{execute, execute_with_cancel};
|
||||
pub use instance::{register, InstanceConfig, Registration};
|
||||
pub use plan::{FileRole, InstallPlan, PlanFile, PlanInput};
|
||||
pub use report::{FileFailure, FileOutcome, FileStatus, InstallReport, ProgressSink};
|
||||
pub use source::{ManifestFreshness, RemoteSource, MANIFEST_TTL_MS};
|
||||
|
||||
use crate::download::{DownloadError, DownloadErrorKind};
|
||||
@@ -58,6 +60,10 @@ pub enum InstallErrorKind {
|
||||
Io,
|
||||
/// 元数据解析失败(清单/版本 JSON/资源索引坏了)。
|
||||
Metadata,
|
||||
/// 用户取消(`execute_with_cancel` 看到取消标志;不是失败,也不是网络问题)。
|
||||
///
|
||||
/// CLI 永远走 [`execute`](不传取消标志),所以这个大类只可能从 GUI 的取消路径出来。
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl InstallErrorKind {
|
||||
@@ -70,6 +76,7 @@ impl InstallErrorKind {
|
||||
Self::Integrity => "install_integrity",
|
||||
Self::Io => "install_io",
|
||||
Self::Metadata => "install_metadata",
|
||||
Self::Cancelled => "install_cancelled",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! 安装报告与进度回调的形状(M105 从 `execute.rs` 搬出,守 400 行硬线)。
|
||||
//!
|
||||
//! 纯数据 + 纯方法:`execute.rs`(执行)与 CLI(汇总打印)都读它,谁也不改它。
|
||||
//! 搬移是**逐字**的,只有 `use` 断了回来(原来靠 `execute.rs` 的 import 活着)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::download::DownloadErrorKind;
|
||||
|
||||
use super::plan::FileRole;
|
||||
|
||||
/// 单个文件的结局。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FileStatus {
|
||||
/// 本次真的下载(或从缓存直取)并落盘。
|
||||
Downloaded,
|
||||
/// 已存在且校验通过,跳过。
|
||||
Skipped,
|
||||
/// 失败(原因在 [`FileOutcome::failure`])。
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl FileStatus {
|
||||
/// 稳定字符串 id。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Downloaded => "downloaded",
|
||||
Self::Skipped => "skipped",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次失败的分类信息(调用方据此给 `install_network` / `install_integrity` 等稳定码)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileFailure {
|
||||
/// 下载错误分类。
|
||||
pub kind: DownloadErrorKind,
|
||||
/// 人读说明。
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 一个文件的执行结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileOutcome {
|
||||
/// 角色。
|
||||
pub role: FileRole,
|
||||
/// 人读标识。
|
||||
pub name: String,
|
||||
/// 落盘路径。
|
||||
pub dest: std::path::PathBuf,
|
||||
/// 结局。
|
||||
pub status: FileStatus,
|
||||
/// 实际字节数(跳过时为盘上大小)。
|
||||
pub bytes: u64,
|
||||
/// 成功的源标识(`bmclapi` / `official` / `cache` / `metadata`)。
|
||||
pub source: Option<String>,
|
||||
/// 失败详情。
|
||||
pub failure: Option<FileFailure>,
|
||||
}
|
||||
|
||||
impl FileOutcome {
|
||||
/// 是否失败。
|
||||
#[must_use]
|
||||
pub fn failed(&self) -> bool {
|
||||
self.status == FileStatus::Failed
|
||||
}
|
||||
}
|
||||
|
||||
/// 进度回调:每完成一个文件调用一次(可跨线程,由执行器持有 `Arc`)。
|
||||
///
|
||||
/// CLI 用它往 stderr 打实时进度;GUI 侧直接消费 `ProgressEvent` 事件流即可,不必用这个。
|
||||
pub type ProgressSink = Arc<dyn Fn(&FileOutcome) + Send + Sync>;
|
||||
|
||||
/// 一次安装的执行结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InstallReport {
|
||||
/// 逐文件结果(顺序不保证,按角色/大小汇总即可)。
|
||||
pub files: Vec<FileOutcome>,
|
||||
/// 版本清单落盘路径;`None` = 有文件失败,故意没写(见 `execute` 的说明)。
|
||||
pub version_json: Option<PathBuf>,
|
||||
/// 计划阶段带来的告警(缺件降级说明)。
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl InstallReport {
|
||||
/// 真正下载的文件数。
|
||||
#[must_use]
|
||||
pub fn downloaded(&self) -> usize {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|file| file.status == FileStatus::Downloaded)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 跳过(已存在且校验通过)的文件数。
|
||||
#[must_use]
|
||||
pub fn skipped(&self) -> usize {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|file| file.status == FileStatus::Skipped)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// 失败的文件数。
|
||||
#[must_use]
|
||||
pub fn failed(&self) -> usize {
|
||||
self.files.iter().filter(|file| file.failed()).count()
|
||||
}
|
||||
|
||||
/// 本次写入的字节数(只算真正下载的)。
|
||||
#[must_use]
|
||||
pub fn bytes(&self) -> u64 {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|file| file.status == FileStatus::Downloaded)
|
||||
.map(|file| file.bytes)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// 是否全部就绪(无失败)。
|
||||
#[must_use]
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.failed() == 0
|
||||
}
|
||||
|
||||
/// 失败文件列表(最多取前 `limit` 条,给 CLI 拼提示用)。
|
||||
#[must_use]
|
||||
pub fn failures(&self, limit: usize) -> Vec<&FileOutcome> {
|
||||
self.files
|
||||
.iter()
|
||||
.filter(|file| file.failed())
|
||||
.take(limit)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 失败里最"严重"的大类:有校验不符就先报它,其余按网络/IO 归。
|
||||
#[must_use]
|
||||
pub fn failure_kind(&self) -> Option<DownloadErrorKind> {
|
||||
let mut fallback = None;
|
||||
for file in self.files.iter().filter(|file| file.failed()) {
|
||||
let Some(failure) = &file.failure else {
|
||||
continue;
|
||||
};
|
||||
if failure.kind == DownloadErrorKind::Integrity {
|
||||
return Some(DownloadErrorKind::Integrity);
|
||||
}
|
||||
fallback.get_or_insert(failure.kind);
|
||||
}
|
||||
fallback
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,9 @@ use crate::download::{
|
||||
};
|
||||
use crate::game::arguments::Environment;
|
||||
use crate::game::install::assets::{AssetIndex, AssetObject};
|
||||
use crate::game::install::execute::{current_bytes, execute, FileOutcome, FileStatus};
|
||||
use crate::game::install::execute::{
|
||||
current_bytes, execute, execute_with_cancel, FileOutcome, FileStatus,
|
||||
};
|
||||
use crate::game::install::plan::{FileRole, InstallPlan, PlanFile, PlanInput};
|
||||
use crate::game::layout::GameLayout;
|
||||
use crate::game::manifest::VersionJson;
|
||||
@@ -168,6 +170,50 @@ async fn skips_existing_files_and_writes_version_json() {
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// **取消:一个文件都不下、版本清单不落盘**(M105 的「半成品不许变成品」)。
|
||||
///
|
||||
/// 标志**开工前**就置位 ⇒ 走「一个文件都不起」那条路,全程零网络(所以这条能进 CI,
|
||||
/// 真网络的那条在 `crates/presenter/src/host/cancel_tests.rs`,`#[ignore]` 守着)。
|
||||
///
|
||||
/// 抓得住:取消返回 `Cancelled` 大类、`version_json` 没写、盘上没多出 jar。
|
||||
/// 抓不住:在飞的文件被掐断的那一刻(那要真网络 + 真字节流,见 presenter 的实测装置)。
|
||||
#[tokio::test]
|
||||
async fn cancelling_before_the_first_file_downloads_nothing_and_writes_no_manifest() {
|
||||
let root = temp_root("cancel");
|
||||
let layout = GameLayout::new(&root);
|
||||
// jar **不在**盘上:真跑就会去下(本用例不许它跑起来)
|
||||
let sha1 = "3f".repeat(20);
|
||||
let resolved = VersionJson::from_json_str(&format!(
|
||||
r#"{{"id": "1.20.1", "mainClass": "net.minecraft.client.main.Main",
|
||||
"downloads": {{"client": {{"sha1": "{sha1}", "size": 32, "url": "https://piston-data.mojang.com/v1/objects/{sha1}/client.jar"}}}}}}"#
|
||||
))
|
||||
.expect("清单应可解析");
|
||||
let plan = InstallPlan::build(PlanInput {
|
||||
layout: &layout,
|
||||
version_id: "1.20.1",
|
||||
resolved: &resolved,
|
||||
asset_index: None,
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect("组计划应成功");
|
||||
|
||||
let cancel = std::sync::atomic::AtomicBool::new(true);
|
||||
let error = execute_with_cancel(&engine(&root), &plan, 4, None, Some(&cancel))
|
||||
.await
|
||||
.expect_err("取消了就不要返回 report");
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
crate::game::install::InstallErrorKind::Cancelled,
|
||||
"取消是独立大类,不是网络/IO 失败"
|
||||
);
|
||||
assert!(
|
||||
!plan.version_json_path.is_file(),
|
||||
"取消后不许落版本清单(否则半成品会被当成已安装)"
|
||||
);
|
||||
assert!(!layout.version_jar("1.20.1").is_file(), "一个文件都不该下");
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reinstalling_twice_is_idempotent() {
|
||||
let root = temp_root("idempotent");
|
||||
|
||||
Reference in new issue
Block a user