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:
AskaEth committed 2026-11-04 22:12:22 +08:00
1 parent d690d09b84
commit 30b1ff6f48
39 files changed
+2550 -518

No files matched your search

+101 -149
View File
@@ -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) {
+8 -1
View 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",
}
}
}
+154
View File
@@ -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
}
}
+47 -1
View File
@@ -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");