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:
1 parent
30b1ff6f48
commit
609bcb3044
14 files changed
+240
-23
No files matched your search
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user