fix(dlflow): M105 r1 —— 取消后不再逐个派发剩余文件;取消证据补强;取消提示进 i18n

复审三条(p2-3items / fix-then-merge,其中两条按 p1 对待):

**P2-1 取消后仍为每个剩余文件各发一次请求(p1)**
`select` 先轮询左支 ⇒ `install_one` 第一次 poll 就把 `engine.download(…)` 派出去了;
只把标志检查写在右支,等于取消后「每一个剩余 item 都创建并至少 poll 一次」
(一次整文件 sha1 + 一次请求),盘上已就绪的还会第一 poll 就 `Ready` 并回调 `sink`。
修:`async move` 第一句就 `if is_cancelled(cancel) { return None; }`,这些 item 连
`install_one` 都不进。守门用例 `cancelling_mid_run_stops_creating_remaining_files`
(三文件全在盘上、并发 1、sink 首次回调即置标志 ⇒ 数 sink 回调次数):
修后 1,删掉那两行变异后 **3**(见报告,红/绿均实跑)。
模块文档同步改成与实现一致,并把复审点名的固有边界写进注释:取消瞬间最多有
`concurrency`(GUI 传 16)个 future 已经过了那道门,其中「盘上已就绪」的仍会跑完自己的
回调(靠 presenter 的安装代次丢掉这些进度事件),真在下载的会在同一次 poll 被取消。

**P2-3 真网络装置的断言比声称的弱,且有一条探的是恒不成立的路径(p1)**
① `.ullpart` 原来只断言「5 s 后不再增长」⇒「留下一个不增长的非 0 中间文件」会绿;
改成 `assert_eq!(parts_after, 0)`。
② 实例登记原来探 `<game>/instances`(根本不存在 ⇒ 恒 false,什么也没证明);
改成读 `LauncherLayout::profiles_config()` 的 `read_profiles()` 并断言 0 条。
③ 模块文档里那条探针的路径一并订正。
变异自证:留一个 20 字节 `.ullpart` ⇒ 红(`left: 20 / right: 0`);
走真 `instance::register()` 造一条 profile ⇒ 红(`profiles.json 里 1 条实例`)。

**P2-2 取消文案仍是硬编码中文,与新选项区同屏一中一英**
`presenter` 没有 i18n 依赖,所以不是搬常量能解决的。改成发稳定信号:
新增 `CoreEvent::InstallCancelled`(不带文本),`AppState::install_cancelled` 只计数;
app 层在 `refresh_view` 里比对计数、按 `Key::DownloadCancelled` 现求文本弹一次 Toast
(`pages::download::cancelled_toast`)。`dispatch.rs` 的中文 Toast 删掉。
语言入口收成 `app::view::ui_lang()` 一处(现恒为英文;M106 接上语言只改这里,
M106 合并前中文译文在产品路径不可达)。守门:`cancelling_announces_once_in_the_page_language`
(只弹一条、文案来自目录、不含中文、重复 refresh 不重复弹)。

门禁(真实退出码):fmt=0、clippy(--workspace --all-targets -j 2 -D warnings)=0、
`cargo test -p unlucky-app-iced --lib`=0(440 passed)、`cargo test -p unlucky-core`=0、
`cargo test --workspace --exclude unlucky-app -j 2`=0(57 个 target 全 ok,0 failed)。
This commit is contained in:
AskaEth committed 2026-11-04 22:12:22 +08:00
1 parent 30b1ff6f48
commit 609bcb3044
14 files changed
+240 -23

No files matched your search

+30 -4
View File
@@ -23,15 +23,32 @@
//! [`execute`] 与 CLI 的契约**一字不变**(不传取消标志 = 今天的行为);
//! 取消走 [`execute_with_cancel`],判据三条:
//!
//! 1. **不再起新文件**:每个文件开工前看一眼标志,置位就不进 `install_one`;
//! 1. **不再起新文件**:流里**每个 item 的第一句**就看标志,置位直接返回 `None` ⇒
//! 那些 item **连 `install_one` 都不进**(不发请求、不算整文件 sha1、不回调 `sink`)。
//! 这一句不能挪到 `select` 的右支:`select` 先轮询左支,左支第一次 poll 就把
//! `engine.download(…)` 派出去了(复审 P2-1 就是从这条抓出来的)。
//! 2. **在飞的文件就地掐断**:`install_one` 与「取消 future」`select` 赛跑,
//! 取消赢就 drop 掉下载 future —— reqwest 的响应流随之关闭(socket 断开),
//! `.ullpart` 由 [`PartFile`](crate::download::PartFile) 的 `Drop` 清掉(不留半截垃圾);
//! `.ullpart` 由 [`PartFile`](crate::download::PartFile) 的 `Drop` 清掉(不留半截垃圾)。
//! 3. **半成品不许变成品**:取消后不再写版本 JSON,返回
//! [`InstallErrorKind::Cancelled`](super::InstallErrorKind),调用方(presenter)据此把阶段
//! 记成「已取消」而不是「失败」。
//!
//! 于是取消延迟 = **一个数据块的唤醒**(不是「等一个文件下完」,也不是「等这一段跑完」)。
//!
//! ## 固有边界(复审要求留档,别把它当 bug 修)
//!
//! `buffer_unordered(concurrency)` 在取消那一刻**已经有最多 `concurrency`(GUI 传 16)个 future
//! 被拉起来了**,它们已经过了第 1 条那道门。所以最坏情况下这 16 个文件里:
//!
//! - **盘上已就绪的**(第一 poll 就 `Ready`)会照常「跑完」并被回调一次 `sink` ——
//! presenter 侧靠**安装代次**(`CoreHost::install_epoch`)把这些回调带来的进度事件丢掉,
//! 所以面板不会因此复燃;
//! - **真在下载的**会在同一次 poll 的右支被取消掉(future drop、连接关掉、`.ullpart` 清掉)。
//!
//! 也就是说:**「取消之后一个字节都不会再写」这句话只对「还没被拉起的 item」成立**;
//! 在飞的 16 个里已就绪的那些仍会走完自己的回调。要连这一步都收掉,得让
//! `buffer_unordered` 支持「取消即清空队列」,本轮不做(不在 M105 的验收口径里)。
use std::future::poll_fn;
use std::path::Path;
@@ -160,8 +177,17 @@ pub async fn execute_with_cancel(
.map(|file| {
let sink = sink.clone();
async move {
// 取消后不再起新文件;在飞的那个与取消 future 赛跑,取消赢就 drop 掉下载 future
// (响应流关闭 + `.ullpart` 由 `PartFile::Drop` 清掉),于是取消延迟 = 一个数据块。
// **每个 item 的第一句就查标志**(复审 P2-1,p1):`select` 先轮询左支,
// 左支第一次 poll 就会把 `engine.download(...)` 派发出去 —— 只把标志检查写在
// `select` 的右支,等于「取消之后每个剩余文件仍各发一次整文件 sha1 + 一次请求」,
// 1.20.1 那种四位数文件的 plan 会一直走到列表末尾,而且「盘上已就绪」的文件
// 第一 poll 就 `Ready` ⇒ 还会各回调一次 `sink`。
// 放在这里,取消后这些 item **连 `install_one` 都不进**。
if is_cancelled(cancel) {
return None;
}
// 已经开始的那些(`buffer_unordered` 在飞,最多 `concurrency` 个)与取消 future 赛跑:
// 取消赢就 drop 掉下载 future(响应流关闭 + `.ullpart` 由 `PartFile::Drop` 清掉)。
let outcome = match cancel {
Some(flag) => {
let download = install_one(engine, file);
@@ -314,3 +314,84 @@ fn failure_kind_prefers_integrity() {
assert_eq!(report.failure_kind(), Some(DownloadErrorKind::Integrity));
assert_eq!(report.failures(1).len(), 1);
}
/// **取消之后,剩下的文件连 `install_one` 都不许进**(复审 P2-1 的守门,p1)。
///
/// 现场(修前):标志检查只写在 `select` 的右支,而 `select` 先轮询左支 —— 左支第一次 poll
/// 就把 `engine.download(…)` 派出去了。于是取消后 `buffer_unordered` 立刻让出槽位、流继续被拉,
/// **每一个剩余 item 都会各发一次整文件 sha1 + 一次请求**,1.20.1 那种四位数文件的 plan 会一直
/// 走到列表末尾;盘上已就绪的文件第一 poll 就 `Ready` ⇒ 还会各回调一次 `sink`。
///
/// 这条用例把它钉成可数的东西:三个文件全在盘上(`install_one` 直接判 Skipped、零网络),
/// 并发 1,`sink` 第一次回调时置取消标志 —— 于是「取消后还剩几个文件被处理」= `sink` 的调用次数。
/// 修好后必须是 **1**(只有第一个),修前是 **3**。
///
/// 抓得住:`async move` 开头那句 `if is_cancelled(cancel) { return None; }` 被删掉/挪到 `select` 右支。
/// 抓不住:在飞的那一个文件被掐断的时机(那要真字节流,见 presenter 的真网络装置)。
#[tokio::test]
async fn cancelling_mid_run_stops_creating_remaining_files() {
let root = temp_root("cancel-mid");
let layout = GameLayout::new(&root);
let jar = b"client jar payload".to_vec();
let sha1 = sha1_of(&jar);
write_file(&layout.version_jar("1.20.1"), &jar);
let resolved = VersionJson::from_json_str(&format!(
r#"{{"id": "1.20.1", "mainClass": "net.minecraft.client.main.Main",
"downloads": {{"client": {{"sha1": "{sha1}", "size": {}, "url": "https://piston-data.mojang.com/v1/objects/{sha1}/client.jar"}}}}}}"#,
jar.len()
))
.expect("清单应可解析");
let mut plan = InstallPlan::build(PlanInput {
layout: &layout,
version_id: "1.20.1",
resolved: &resolved,
asset_index: None,
environment: &Environment::host(),
})
.expect("组计划应成功");
// 再挂两个"已在盘上"的库文件:内容与声明的 sha1 一致 ⇒ 跳过判定成立、零网络。
for name in ["org.example:a:1.0", "org.example:b:1.0"] {
let bytes = format!("payload for {name}").into_bytes();
let dest = root
.join("libraries")
.join(format!("{}.jar", name.replace(':', "-")));
write_file(&dest, &bytes);
plan.files.push(PlanFile {
role: FileRole::Library,
name: name.to_string(),
url: format!("https://example.invalid/{name}.jar"),
sha1: Some(sha1_of(&bytes)),
size: Some(bytes.len() as u64),
dest,
resource: ResourceKind::Library,
});
}
assert_eq!(plan.files.len(), 3, "三个文件全在盘上 ⇒ 全程零网络");
let seen = Arc::new(AtomicUsize::new(0));
// `sink` 是 `Fn + 'static`,所以标志也必须 `Arc` 进来;第一次回调就置取消。
let flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
let sink_seen = Arc::clone(&seen);
let sink_flag = Arc::clone(&flag);
let sink: crate::game::install::ProgressSink = Arc::new(move |_outcome: &FileOutcome| {
sink_seen.fetch_add(1, Ordering::SeqCst);
sink_flag.store(true, Ordering::SeqCst);
});
let error = execute_with_cancel(&engine(&root), &plan, 1, Some(sink), Some(flag.as_ref()))
.await
.expect_err("取消之后不许返回成功 report");
assert_eq!(
error.kind(),
crate::game::install::InstallErrorKind::Cancelled
);
assert_eq!(
seen.load(Ordering::SeqCst),
1,
"取消只该让**第一个**文件走完回调;剩下的 item 必须连 install_one 都不进\
(修前这里会是 3:每个剩余文件各多算一次 sha1、各多发一次请求)"
);
assert!(!plan.version_json_path.is_file(), "取消后不许落版本清单");
std::fs::remove_dir_all(&root).ok();
}