fix(instances): M302 r1 P2 —— 冷启动首帧三态(占位 / 真空态 / 有实例)+ N1~N4
复审 P2-1:扫盘搬后台之后,`App::dispatch(RefreshAll)` 返回时快照还没到(`pump` 只抽队列、
不 `tick`),**首帧必然**在快照之前。改前那一帧画的是「计数 0 + 还没有实例 + 实心导入 CTA」
——一句假话,而且 CTA 能点。改后三态分开:
- 真源放 presenter:`dto::InstancePhase { Scanning, Stalled, Ready }`,`AppState::instances_phase`
在 `CoreEvent::InstancesChanged` 落地时置 `Ready`;**不从 `instances.is_empty()` 推**(那就是 bug 形态)。
- `Scanning` = 首个快照未到:详情列画「正在扫描实例…」占位(新 key `InstancesScanning`,golden 两语),
列头**不写计数**(藏起来,不写 0 也不写「—」),**没有**空态 CTA。
- `Stalled` = 有界兜底:`host::game::SCAN_STALL = 15 s`(= 仓库内最坏实测 1.35 s 的约 11 倍,
理由在常量文档里)。超时只**报**一次、不掐线程;迟到的结果照样落地(代次没变)。
那一屏给一枚「重新扫描」→ `InstanceMessage::RetryScan` → 新命令 `AppCommand::RefreshInstances`
(只重扫实例目录,不重拉版本清单)。那一支在 `selected_instance_id()` 守卫**之前**——冷启动没有选中项。
- `Ready` = 收到过快照(空列表也算):这时才轮到 M273 的两种空态(没选中 / 一个都没有)。
- 已就绪时**不发** `Scanning`:否则每次刷新列表都退回占位(把卡顿换成闪屏)。
判据(都能红,变异原文见报告):
- `pages/instances/tests/cold_boot.rs`:真控件树(未就绪 7 枚按钮 = 有实例那一屏,零实例 = 8 枚,
多的那枚就是 CTA;每行 2 条文字节点的差分数行数)+ 源码结构(`Scanning`/`Stalled` 两支不碰
`detail_empty` 与空态文案、超时那支接 `RetryScan`、三个 key 真的被读)。
- `app/update/instances/scan_phase.rs`:首帧 = `Scanning`、空快照 = `Ready`、`Stalled` 经重试回到
`Scanning`、重试只派 `RefreshInstances`。夹具 `ULL_INSTANCE_PHASE` 只给出图(与 `ULL_SAVES_FIXTURE` 同一条纪律)。
顺手收 N1~N4(全是措辞 / 观测口径,不动行为):
- N1 两处 docstring 不再声称「`admit` 写成 `>=` 会红」(实测等价),改成「去掉或恒真才红」;
- N2 收尾用例那条 Idle 断言的失败消息改准(它不区分新旧快照,区分靠条数/代次断言);
- N3 boot 双刷新必然丢 1 份 ⇒ 丢弃日志分成「此前没有快照落地(启动期,非交错)」与「已有落地(真交错)」,
`Scan::dropped` 的文档登记这条已知噪音;
- N4 app 用例 docstring 收窄成「一拍无事件不清表、不钉时钟」(它绕过 `App::dispatch`,抓不住那 5 行)。
拆文件守 400 行:`detail/scan.rs`(分派 + 占位)、`refresh_stall_tests.rs`(空根 / 超时)、
`app/update/instances/scan_phase.rs`(app 级三态用例);`i18n` 的 `PAGE_SOURCES` 同步加上新页面文件。
This commit is contained in:
1 parent
a0413a5f94
commit
f03411d58b
26 files changed
+1010
-70
No files matched your search
@@ -24,6 +24,7 @@ use crate::dto::{
|
||||
DiagnosisReport,
|
||||
InstallProgressView,
|
||||
InstanceId,
|
||||
InstancePhase,
|
||||
InstanceRuntime,
|
||||
InstanceSummary,
|
||||
JavaPageView,
|
||||
@@ -93,6 +94,13 @@ pub struct LaunchFinished {
|
||||
pub enum CoreEvent {
|
||||
/// 实例列表整体刷新
|
||||
InstancesChanged(Vec<InstanceSummary>),
|
||||
/// 实例列表的首屏三态变化(M302)。
|
||||
///
|
||||
/// 只发「没就绪」的两态:`Scanning` = 新一轮扫描起跑(冷启动 / 点了「重新扫描」),
|
||||
/// `Stalled` = 起跑后超过 `host::game::SCAN_STALL`(15 s)仍没快照。
|
||||
/// **`Ready` 不从这里发**:收到 [`CoreEvent::InstancesChanged`] 本身就是就绪的判据
|
||||
/// (一条通道一个含义,免得「快照到了但没就绪」这种不可能态被写出来)。
|
||||
InstancePhaseChanged(InstancePhase),
|
||||
/// 单个实例运行时状态变化(启动进度/运行中/失败)
|
||||
InstanceRuntimeChanged {
|
||||
/// 实例 id
|
||||
@@ -198,6 +206,11 @@ pub enum AppEvent {
|
||||
pub enum AppCommand {
|
||||
/// 重新扫描实例目录 + 全部页面快照(启动时与手改文件后)
|
||||
RefreshAll,
|
||||
/// **只**重扫实例目录(M302:「扫描超时 · 重新扫描」按钮)。
|
||||
///
|
||||
/// 与 [`AppCommand::RefreshAll`] 的区别:不重拉版本清单(一条网络请求)、不重读账户 /
|
||||
/// 设置 / Java / 更新快照——用户点的只是「把列表再扫一遍」。
|
||||
RefreshInstances,
|
||||
/// 启动实例
|
||||
LaunchInstance(InstanceId),
|
||||
/// 停止实例(含取消启动中的管线)
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
//! 路径对外**一个字没变**(`unlucky_presenter::dto::InstanceSummary` 等仍从这里取),
|
||||
//! 拆的是文件不是 API。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod create;
|
||||
mod loader;
|
||||
mod runtime;
|
||||
@@ -41,3 +43,33 @@ pub const DISCOVERED_ID_PREFIX: &str = "ext-";
|
||||
pub fn is_discovered_instance(id: &str) -> bool {
|
||||
id.starts_with(DISCOVERED_ID_PREFIX)
|
||||
}
|
||||
|
||||
/// 实例列表的**首屏三态**(M302):区分「还没扫完」和「确实一个都没有」。
|
||||
///
|
||||
/// ## 为什么必须要这一位(复审 r1 P2-1)
|
||||
///
|
||||
/// 扫盘搬进后台线程之后,`App::dispatch(RefreshAll)` 返回时快照还没到(`pump` 只抽队列、
|
||||
/// 不 `tick`),所以**首帧**的 `AppState::instances` 是空的。用「`instances.is_empty()`」去猜
|
||||
/// 状态就必然把「还没扫完」画成「还没有实例」——一句假话,还配一个「导入已有版本」的实心 CTA
|
||||
/// (用户可能真去点)。真源必须是 host 自己知道的那一位,不是列表长度。
|
||||
///
|
||||
/// ## 语义(三态都要能一句话说清)
|
||||
///
|
||||
/// | 值 | 什么时候是它 | 界面 |
|
||||
/// |---|---|---|
|
||||
/// | [`Self::Scanning`] | **初值**:这一进程还没有任何一份实例快照落地 | 占位「正在扫描实例…」,不写计数、不画空态 CTA |
|
||||
/// | [`Self::Stalled`] | 扫描起跑后超过 `host::game::SCAN_STALL` 仍没有快照(线程 panic / 卡在慢盘) | 占位 + 「重新扫描」按钮 |
|
||||
/// | [`Self::Ready`] | 收到过至少一份 `CoreEvent::InstancesChanged` | 列表(空的也行——那就是真数据) |
|
||||
///
|
||||
/// **`Ready` 不表示「列表非空」**,也**不表示「扫描成功」**(没有失败态:扫描不返回错误,
|
||||
/// 只有「还没回来」):空列表 + `Ready` = 确实没有实例,那时才画「还没有实例」。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
|
||||
pub enum InstancePhase {
|
||||
/// 首个快照还没到(进程刚起来,或刚点过「重新扫描」)
|
||||
#[default]
|
||||
Scanning,
|
||||
/// 首个快照迟迟没到(有界兜底,见 `host::game::SCAN_STALL`)
|
||||
Stalled,
|
||||
/// 收到过快照:列表内容就是真数据(可能是空)
|
||||
Ready,
|
||||
}
|
||||
@@ -61,9 +61,9 @@ pub use install::{
|
||||
InstallStage,
|
||||
};
|
||||
pub use instances::{
|
||||
is_discovered_instance, CreateRequest, InstanceId, InstanceRuntime, InstanceSummary,
|
||||
LaunchFailureCause, LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind,
|
||||
Progress, DISCOVERED_ID_PREFIX,
|
||||
is_discovered_instance, CreateRequest, InstanceId, InstancePhase, InstanceRuntime,
|
||||
InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFailureView, LaunchStage,
|
||||
LoaderInfo, LoaderKind, Progress, DISCOVERED_ID_PREFIX,
|
||||
};
|
||||
pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView};
|
||||
pub use launch_args::{LaunchArgsPatch, LaunchArgsView};
|
||||
|
||||
@@ -24,6 +24,8 @@ impl CoreHost {
|
||||
fn dispatch_inner(&mut self, command: AppCommand) {
|
||||
match command {
|
||||
AppCommand::RefreshAll => self.refresh_all(),
|
||||
// M302:「扫描超时 · 重新扫描」只重扫实例目录(不重拉版本清单、不重读账户/设置)
|
||||
AppCommand::RefreshInstances => self.refresh_instances(),
|
||||
AppCommand::LaunchInstance(id) => self.start_launch(&id),
|
||||
AppCommand::StopInstance(id) => match self.running_pid(&id) {
|
||||
Some(pid) => match launch::stop(pid) {
|
||||
|
||||
@@ -17,11 +17,29 @@
|
||||
//! 提前掐断它省下的只是它自己那点 CPU,而掐断的代价是一个可能永远不回来的快照。
|
||||
//! 于是一条路口:**不取消线程,只丢弃过期结果**(丢掉的是那一份,不是一个状态)。
|
||||
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::dto::{CoreEvent, InstancePhase};
|
||||
|
||||
use super::super::job::{spawn, HostTask, JobSender};
|
||||
use super::super::HostEnv;
|
||||
|
||||
/// 首个实例快照的等待上限(M302 的**有界兜底**)。
|
||||
///
|
||||
/// ## 取值依据(不是随手挑的)
|
||||
///
|
||||
/// 仓库内最坏的一次实测扫盘是 **1.35 s**(官方根 200 个版本 + 10 份外部档案,M297/M274 r1);
|
||||
/// 15 s ≈ 它的 11 倍,足够盖住慢盘 / 网络盘上的一次 `read_dir` 抖动,又不至于让用户在
|
||||
/// 「正在扫描实例…」上等到放弃。真正该防的不是「慢一点」,而是**线程 panic / 永远不回来**——
|
||||
/// 那种情况下没有兜底,占位永远不会消失。
|
||||
///
|
||||
/// ## 超时之后发生什么
|
||||
///
|
||||
/// **不取消扫描线程**(理由见模块文档):面板先翻到「扫描实例比平时久」+ 可点的「重新扫描」,
|
||||
/// 而那一轮的线程仍在跑、迟到的结果照样落地(代次没变,`admit` 照放)⇒ 占位自己消失。
|
||||
/// 只有「永远不回来」那种才需要用户点重试。
|
||||
pub const SCAN_STALL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// 一次刷新领到的代次(`0` 是「还没刷新过」的初值,第一次刷新从 `1` 开始)。
|
||||
pub type Epoch = u64;
|
||||
|
||||
@@ -35,8 +53,19 @@ pub struct Scan {
|
||||
epoch: Epoch,
|
||||
/// 最近一次被采纳的扫描
|
||||
adopted: Option<Adopted>,
|
||||
/// 被丢掉的**旧代次**结果条数(> 0 就证明「先发后到」真的发生过)
|
||||
/// 被丢掉的**旧代次**结果条数(> 0 就说明「先发后到」真的发生过)
|
||||
///
|
||||
/// **已知噪音(复审 N3)**:`CoreHost::build` 的 `refresh_all` 与 `on_boot` 的
|
||||
/// `AppCommand::RefreshAll` 各领一代 ⇒ **每次冷启动必然丢 1 份**。所以这个计数在人读日志上
|
||||
/// 必须配合「此前有没有快照落地过」一起看(`handle::apply_instances` 的丢弃日志已经把两种
|
||||
/// 成因分开写),单看「> 0」会误判成交错。
|
||||
dropped: u64,
|
||||
/// 本轮扫描的起跑时刻(超时兜底的唯一时钟来源;`None` = 结果已经落地 / 还没起跑)
|
||||
started: Option<Instant>,
|
||||
/// 本轮是否已经报过超时(`tick` 每拍都会问,只报一次,否则面板/日志会被刷成噪音)
|
||||
stalled: bool,
|
||||
/// 本轮的超时上限(生产恒为 [`SCAN_STALL`];单测可以调小,不必等真实的 15 s)
|
||||
budget: Duration,
|
||||
}
|
||||
|
||||
/// 一次被采纳的扫描:代次、实例数、纯扫盘耗时(毫秒)。
|
||||
@@ -56,10 +85,43 @@ impl Scan {
|
||||
/// 只增不减(`wrapping_add` 与 `install_epoch` 同款):**代次相等才是当代次**,
|
||||
/// 于是「上一轮还在飞的那份」无论什么时候回来都进不了门。
|
||||
pub fn begin(&mut self) -> Epoch {
|
||||
self.begin_with(SCAN_STALL)
|
||||
}
|
||||
|
||||
/// 同 [`Scan::begin`],但显式给上限(单测用;生产只有上面那一个入口)。
|
||||
pub fn begin_with(&mut self, budget: Duration) -> Epoch {
|
||||
self.epoch = self.epoch.wrapping_add(1);
|
||||
self.started = Some(Instant::now());
|
||||
self.stalled = false;
|
||||
self.budget = budget;
|
||||
self.epoch
|
||||
}
|
||||
|
||||
/// 单测专用:把**当前这一轮**的上限调小(生产路径的上限只由 [`SCAN_STALL`] 定)。
|
||||
///
|
||||
/// 存在的理由:超时路径要能在单测里真跑一遍(事件发出去 / 只发一次 / 迟到结果清掉它),
|
||||
/// 而等 15 s 会让整个门禁慢十几秒。**调小不等于换阈值**:阈值本身仍只有 [`SCAN_STALL`]。
|
||||
#[cfg(test)]
|
||||
pub fn shorten_budget(&mut self, budget: Duration) {
|
||||
self.budget = budget;
|
||||
}
|
||||
|
||||
/// 这一轮**还要不要**报超时(有界兜底,判据是 [`SCAN_STALL`])。
|
||||
///
|
||||
/// `true` 只在「有扫描在飞 + 已经超过上限 + 这一轮还没报过」时返回一次;报过之后
|
||||
/// 每拍再问都是 `false`(`tick` 每帧都会问,重复报会把面板刷成噪音)。
|
||||
/// 结果落地([`Scan::adopt`])会停表,之后永远 `false`。
|
||||
pub fn stall(&mut self) -> bool {
|
||||
let Some(started) = self.started else {
|
||||
return false;
|
||||
};
|
||||
if self.stalled || started.elapsed() < self.budget {
|
||||
return false;
|
||||
}
|
||||
self.stalled = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// 这份结果还是不是当代次的?不是的话记一笔丢包(可观测)并返回 `false`。
|
||||
///
|
||||
/// 判据是**相等**而不是「更大」:代次是唯一的命名,只有最新一轮发出去的那份作数。
|
||||
@@ -73,6 +135,8 @@ impl Scan {
|
||||
|
||||
/// 记下这一份(只在 [`Scan::admit`] 放行之后调)。
|
||||
pub fn adopt(&mut self, epoch: Epoch, count: usize, elapsed_ms: u64) {
|
||||
// 落地即停表:之后的 `tick` 不该再报超时
|
||||
self.started = None;
|
||||
self.adopted = Some(Adopted {
|
||||
epoch,
|
||||
count,
|
||||
@@ -114,8 +178,25 @@ pub fn spawn_scan(env: HostEnv, tx: JobSender, epoch: Epoch) {
|
||||
});
|
||||
}
|
||||
|
||||
impl super::super::CoreHost {
|
||||
/// 每拍看一眼「首个快照」有没有超时(有界兜底)。
|
||||
///
|
||||
/// 为什么要兜底:扫描线程若 panic 或卡在慢盘上,`ready` 就永远是 `false` ⇒ 用户永远看着
|
||||
/// 「正在扫描实例…」。这里只**报**超时(面板翻到可点的「重新扫描」),**不**掐线程——
|
||||
/// 判据与取值理由在 [`Scan::stall`] / [`SCAN_STALL`]。
|
||||
pub(crate) fn check_scan_stall(&mut self) {
|
||||
if !self.instances_scan.stall() {
|
||||
return;
|
||||
}
|
||||
eprintln!(
|
||||
"[instances] 首个快照 {SCAN_STALL:?} 内没回来:报超时(扫描线程仍在跑,迟到的结果照样落地)"
|
||||
);
|
||||
self.send(CoreEvent::InstancePhaseChanged(InstancePhase::Stalled));
|
||||
}
|
||||
}
|
||||
|
||||
/// 单测要 `tick` 抽通道,它的定义在 `crate::dto::Backend` 这条 trait 上。
|
||||
#[cfg(test)]
|
||||
// 单测要 `tick` 抽通道,它的定义在 `crate::dto::Backend` 这条 trait 上。
|
||||
use crate::dto::Backend;
|
||||
|
||||
/// 单测等待「这一代快照落地」的上限。
|
||||
@@ -164,6 +245,10 @@ impl super::super::CoreHost {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "refresh_stall_tests.rs"]
|
||||
mod stall_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "refresh_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,91 @@
|
||||
//! 空数据根 / 超时兜底(M302 的第二组:`Scan` 的落地与 stall 判据)。
|
||||
//!
|
||||
//! 从 `refresh_tests.rs` 拆出来守 400 行硬线(那边是三条**交错**用例 + 账本单测)。
|
||||
//! 这一组验的是另外两件事:
|
||||
//!
|
||||
//! | 用例 | 抓得住 | 抓不住 |
|
||||
//! |---|---|---|
|
||||
//! | [`an_empty_root_publishes_an_empty_snapshot`] | 把「零实例」当成「没扫完」(界面永远停在「正在扫描」),或者反过来在没扫完时发空快照 | 界面上这两态怎么画(`pages/instances/tests/cold_boot.rs`) |
|
||||
//! | [`a_stalled_scan_reports_once_and_a_late_snapshot_still_lands`] | `tick` 不检查超时 / 每拍都报 / 报完把在飞的结果丢掉 | 真实 15 s 阈值合不合适(那要真盘压测) |
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::tests::{host_with, next_scan, snapshots};
|
||||
use super::SCAN_STALL;
|
||||
use crate::dto::{AppEvent, Backend, CoreEvent, InstancePhase};
|
||||
|
||||
// ------------------------------------------------------------------ 空数据根 / 超时兜底
|
||||
|
||||
/// **真·空数据根**:扫完就是一份**空快照**,而且这一代算「已经落地」。
|
||||
///
|
||||
/// 抓得住:把「零实例」误当成「没扫完」(`adopted()` 为空 ⇒ 界面永远停在「正在扫描」);
|
||||
/// 或者反过来在没扫完时发一份空快照(那正是任务禁止的假空态)。
|
||||
/// 抓不住:界面上那两种状态怎么画(`pages/instances/tests/cold_boot.rs` 的控件树 + 出图)。
|
||||
#[test]
|
||||
fn an_empty_root_publishes_an_empty_snapshot() {
|
||||
let (mut host, _env, _ids, _guard) = host_with("empty-root", 0);
|
||||
let _ = host.drain_events();
|
||||
|
||||
host.refresh_instances();
|
||||
host.pump_instances();
|
||||
|
||||
let published = snapshots(host.drain_events());
|
||||
assert_eq!(
|
||||
published.len(),
|
||||
1,
|
||||
"空根也要发一份快照(不发就永远不知道扫完了)"
|
||||
);
|
||||
assert!(published[0].is_empty(), "零档案的根扫出来就是空");
|
||||
assert_eq!(
|
||||
host.instances_scan.adopted().map(|adopted| adopted.count),
|
||||
Some(0),
|
||||
"账本要记「这一代落地了、0 条」——不是「没有落地」"
|
||||
);
|
||||
}
|
||||
|
||||
/// **超时兜底真的会发一条「超时」**,且只发一次;迟到的结果仍然落地(线程没被掐)。
|
||||
///
|
||||
/// 判据用 `shorten_budget` 把上限压到 0,不等真实的 15 s(阈值本身仍是 `SCAN_STALL`)。
|
||||
/// 抓得住:`tick` 没检查超时(用户永远看着「正在扫描实例…」);每拍都发(面板/日志被刷成噪音);
|
||||
/// 报超时之后把在飞的结果丢掉(占位永远不消失)。
|
||||
/// 抓不住:真实的 15 s 阈值合不合适(那要真盘上压测,见 `SCAN_STALL` 的取值说明)。
|
||||
#[test]
|
||||
fn a_stalled_scan_reports_once_and_a_late_snapshot_still_lands() {
|
||||
// 阈值是**产品决定**(取值依据写在常量文档里):改它必须一起改那段话与这条断言
|
||||
assert_eq!(SCAN_STALL, Duration::from_secs(15));
|
||||
let (mut host, _env, _ids, _guard) = host_with("stall", 1);
|
||||
let _ = host.drain_events();
|
||||
|
||||
host.refresh_instances();
|
||||
// 把这一轮的上限压到 0:下一次 `tick` 就该判超时
|
||||
host.instances_scan.shorten_budget(Duration::ZERO);
|
||||
let task = next_scan(&host);
|
||||
|
||||
host.tick(Duration::from_millis(1));
|
||||
let events = host.drain_events();
|
||||
assert!(
|
||||
events.iter().any(|event| matches!(
|
||||
event,
|
||||
AppEvent::Core(CoreEvent::InstancePhaseChanged(InstancePhase::Stalled))
|
||||
)),
|
||||
"超过上限必须报一条「超时」(否则占位永远不消失):{events:?}"
|
||||
);
|
||||
|
||||
// 再拍两下:不许重复报
|
||||
host.tick(Duration::from_millis(1));
|
||||
host.tick(Duration::from_millis(1));
|
||||
assert!(
|
||||
snapshots(host.drain_events()).is_empty()
|
||||
&& !host.drain_events().iter().any(|event| matches!(
|
||||
event,
|
||||
AppEvent::Core(CoreEvent::InstancePhaseChanged(InstancePhase::Stalled))
|
||||
)),
|
||||
"超时只许报一次(tick 每拍都会问)"
|
||||
);
|
||||
|
||||
// 迟到的结果照样落地(代次没变 ⇒ 不掐线程的代价是零)
|
||||
host.handle(task);
|
||||
let published = snapshots(host.drain_events());
|
||||
assert_eq!(published.len(), 1, "报过超时之后,迟到的快照仍然要落地");
|
||||
assert!(!published[0].is_empty());
|
||||
}
|
||||
@@ -16,11 +16,10 @@
|
||||
//! | 启动收尾触发的第三次刷新 | [`a_launch_end_refresh_beats_the_two_that_were_in_flight`] | 收尾后列表不许被扫描期间的旧结果盖回「运行中」 |
|
||||
//!
|
||||
//! 三份都是**真构造**:真临时数据根、真 `profiles.json` 写入、真后台线程跑真 `game::instances`。
|
||||
//! 只有**交付顺序**是人为摆的(把 `rx` 里的任务按住、按想验的顺序喂给 host)——那正是要验的
|
||||
//! 「先发后到」,靠调度器碰运气是测不出确定结论的。
|
||||
//! 只有**交付顺序**是人为摆的(把 `rx` 里的任务按住、按想验的顺序喂给 host)——那正是要验的「先发后到」。
|
||||
//!
|
||||
//! 每条用例的「抓不住」写在各自文档里;共同的一条:**它们不量真实耗时**(扫盘在进程内可能只有
|
||||
//! 几百微秒),也不证明用户能感知的快慢——那要真盘上的 236 ms / 1.35 s 场景,见 M297 的实测口径。
|
||||
//! 每条用例的「抓不住」写在各自文档里;共同的一条:**不量真实耗时**(进程内可能只有几百微秒),
|
||||
//! 也不证明用户能感知的快慢——那要真盘上的 236 ms / 1.35 s 场景(M297 的实测口径)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -35,13 +34,12 @@ use crate::host::{CoreHost, HostEnv, HostTask, LiveLaunch};
|
||||
|
||||
use super::Scan;
|
||||
|
||||
/// 取任务的等待上限(真线程 + 临时根,毫秒级就回来;超时说明真的坏了)。
|
||||
/// 取任务的等待上限(真线程 + 临时根,毫秒级就回来;超时说明真的坏了)
|
||||
const WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
// ------------------------------------------------------------------ 夹具
|
||||
|
||||
/// 临时数据根守卫:测试结束(含断言 panic)时删掉目录。
|
||||
struct TempRoot(std::path::PathBuf);
|
||||
pub(super) struct TempRoot(std::path::PathBuf);
|
||||
|
||||
impl Drop for TempRoot {
|
||||
fn drop(&mut self) {
|
||||
@@ -97,7 +95,7 @@ fn ids(count: usize) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// 建好 `count` 条档案的 host(headless:不跑 `refresh_all`,不起版本清单网络线程)。
|
||||
fn host_with(label: &str, count: usize) -> (CoreHost, HostEnv, Vec<String>, TempRoot) {
|
||||
pub(super) fn host_with(label: &str, count: usize) -> (CoreHost, HostEnv, Vec<String>, TempRoot) {
|
||||
let (env, guard) = root(label);
|
||||
let ids = ids(count);
|
||||
seed(&env, &ids);
|
||||
@@ -107,7 +105,7 @@ fn host_with(label: &str, count: usize) -> (CoreHost, HostEnv, Vec<String>, Temp
|
||||
// ------------------------------------------------------------------ 取件
|
||||
|
||||
/// 从通道里等下一份扫盘任务(**不经过 `tick`**:这里要自己按住顺序)。
|
||||
fn next_scan(host: &CoreHost) -> HostTask {
|
||||
pub(super) fn next_scan(host: &CoreHost) -> HostTask {
|
||||
host.rx.recv_timeout(WAIT).expect("扫盘任务要在 5 s 内回来")
|
||||
}
|
||||
|
||||
@@ -132,7 +130,7 @@ fn ids_of(task: &HostTask) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// 事件队列里所有 `InstancesChanged` 快照(按先后)。
|
||||
fn snapshots(events: Vec<AppEvent>) -> Vec<Vec<InstanceSummary>> {
|
||||
pub(super) fn snapshots(events: Vec<AppEvent>) -> Vec<Vec<InstanceSummary>> {
|
||||
events
|
||||
.into_iter()
|
||||
.filter_map(|event| match event {
|
||||
@@ -240,8 +238,10 @@ fn two_refreshes_in_flight_publish_only_the_newest() {
|
||||
/// → #2 先到、#1 后到。没有代次判据时,`InstancesChanged` 的整体替换会把被删的磁贴**放回来**
|
||||
/// (点进去必然报错,用户以为删失败了)。
|
||||
///
|
||||
/// 抓得住:去掉/写错 `admit`(例如判成 `epoch >= self.epoch`),这条红;把丢掉旧代次那一步做成
|
||||
/// 「发一条空快照」也红(那正是任务里禁止的「清空再重填」)。
|
||||
/// 抓得住:把 `admit` 去掉(或改成恒真)——这条红,因为旧代次那份会被采纳、把被删的磁贴放回来;
|
||||
/// 把丢掉旧代次那一步做成「发一条空快照」也红(那正是任务里禁止的「清空再重填」)。
|
||||
/// **不**抓:把 `admit` 的判据从 `==` 改成 `>=` —— 代次只增不减、在飞任务不可能带着**未来**代次,
|
||||
/// 两种写法行为等价(复审 r1 实测变异③全绿,这条 docstring 原先声称会红,是错的)。
|
||||
/// 抓不住:刷新 #1 与删除动作**同时**发生的窄窗口——本用例把两份扫描都等回来了再摆交付顺序,
|
||||
/// 所以它验的是「顺序」,不是「磁盘并发」。
|
||||
#[test]
|
||||
@@ -350,7 +350,9 @@ fn a_launch_end_refresh_beats_the_two_that_were_in_flight() {
|
||||
assert_eq!(
|
||||
tile.runtime,
|
||||
InstanceRuntime::Idle,
|
||||
"收尾之后的快照必须是 Idle(旧结果若盖回去会带着「运行中」的外观)"
|
||||
"收尾之后磁贴必须是 Idle:运行态是**落地那刻**按 host 当前表盖的,收尾已清表,\
|
||||
所以哪怕是过期快照落地也会是 Idle —— 这条断言只验「盖的结果对」,\
|
||||
新旧之分靠上面的条数与代次断言(`published.len() == 1` / `adopted.epoch` / `dropped`)"
|
||||
);
|
||||
assert_eq!(
|
||||
host.instances_scan.adopted().map(|adopted| adopted.epoch),
|
||||
@@ -364,7 +366,8 @@ fn a_launch_end_refresh_beats_the_two_that_were_in_flight() {
|
||||
|
||||
/// `Scan` 的账目:换代 → 只认当代次 → 旧代次计数 → 记账。
|
||||
///
|
||||
/// 抓得住:把 `admit` 写成 `>=`、把丢弃计数漏掉、把 `adopt` 记到别的代次上。
|
||||
/// 抓得住:把 `admit` 去掉(或改成恒真)、把丢弃计数漏掉、把 `adopt` 记到别的代次上。
|
||||
/// **不**抓:把 `==` 写成 `>=`(代次只增不减 ⇒ 两种写法等价,复审 r1 变异③实测全绿)。
|
||||
/// 抓不住:真实交错的时序(那是上面三条用例的事)。
|
||||
#[test]
|
||||
fn the_ledger_only_admits_the_current_epoch() {
|
||||
|
||||
@@ -275,11 +275,20 @@ impl CoreHost {
|
||||
elapsed_ms: u64,
|
||||
) {
|
||||
if !self.instances_scan.admit(epoch) {
|
||||
// 丢掉的那一份也记一行:交错真的发生时,光看「采纳」那一行是看不出来的
|
||||
// 丢掉的那一份也记一行:交错真的发生时,光看「采纳」那一行是看不出来的。
|
||||
//
|
||||
// 两种成因必须分开写清楚(复审 N3):`CoreHost::build` 的 `refresh_all` 与 `on_boot`
|
||||
// 的 `AppCommand::RefreshAll` 各领一代,所以**每次冷启动都必然丢 1 份**——那是既有
|
||||
// 双刷新的产物,不是交错。判据是「此前有没有任何快照落地过」。
|
||||
eprintln!(
|
||||
"[instances] 丢掉过期扫描(epoch {epoch} ≠ 当代次 {},累计丢 {} 份)",
|
||||
"[instances] 丢弃旧代次扫描(epoch {epoch} ≠ 当代次 {},累计丢 {} 份{})",
|
||||
self.instances_scan.epoch(),
|
||||
self.instances_scan.dropped()
|
||||
self.instances_scan.dropped(),
|
||||
if self.instances_scan.adopted().is_none() {
|
||||
";此前还没有任何快照落地 ⇒ 启动期双刷新,不是交错"
|
||||
} else {
|
||||
";此前已有快照落地 ⇒ 这是真交错"
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -239,6 +239,13 @@ impl CoreHost {
|
||||
/// 掐断它省不下什么,却可能让一份快照永远不回来。
|
||||
fn refresh_instances(&mut self) {
|
||||
let epoch = self.instances_scan.begin();
|
||||
// 还没有任何快照落地(冷启动 / 超时后点「重新扫描」)⇒ 面板回到「正在扫描实例…」。
|
||||
// **已就绪时绝不发这条**:那会让列表在每次刷新时退回占位——把卡顿换成闪屏,正是本 mission 禁止的。
|
||||
if self.instances_scan.adopted().is_none() {
|
||||
self.send(CoreEvent::InstancePhaseChanged(
|
||||
crate::dto::InstancePhase::Scanning,
|
||||
));
|
||||
}
|
||||
game::spawn_scan(self.env.clone(), self.tx.clone(), epoch);
|
||||
}
|
||||
|
||||
@@ -372,6 +379,8 @@ impl Backend for CoreHost {
|
||||
self.handle(task);
|
||||
}
|
||||
self.ticks += 1;
|
||||
// M302 的有界兜底:首个快照太久没到就报超时(面板翻到「重新扫描」,线程不掐)
|
||||
self.check_scan_stall();
|
||||
if self.ticks.is_multiple_of(TAIL_POLL_TICKS) {
|
||||
self.poll_tail();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::dto::{
|
||||
DeviceLoginView,
|
||||
DiagnosisReport,
|
||||
InstallProgressView,
|
||||
InstancePhase,
|
||||
InstanceRuntime,
|
||||
InstanceSummary,
|
||||
JavaPageView,
|
||||
@@ -68,6 +69,13 @@ pub struct AppState {
|
||||
pub router: Router,
|
||||
/// 实例列表快照
|
||||
pub instances: Vec<InstanceSummary>,
|
||||
/// 实例列表的**首屏三态**(M302):`Scanning`(初值,首个快照还没到)/ `Stalled`(超时兜底)/
|
||||
/// `Ready`(收到过快照)。
|
||||
///
|
||||
/// 为什么不从 `instances.is_empty()` 推:扫盘搬进后台线程之后,首帧拿到的必然是一个空
|
||||
/// `Vec`——那和「确实零实例」长得一模一样,用它去猜就会把「还没扫完」画成「还没有实例」
|
||||
/// (复审 r1 P2-1 的假空态)。真源必须是 host 自己报的这一位。
|
||||
pub instances_phase: InstancePhase,
|
||||
/// 当前分类的版本列表
|
||||
pub versions: Vec<VersionSummary>,
|
||||
/// 日志会话列表
|
||||
@@ -161,6 +169,7 @@ impl AppState {
|
||||
prefs: UiPrefs::default(),
|
||||
router: Router::new(),
|
||||
instances: Vec::new(),
|
||||
instances_phase: InstancePhase::Scanning,
|
||||
versions: Vec::new(),
|
||||
log_sessions: Vec::new(),
|
||||
log_lines: Vec::new(),
|
||||
@@ -255,7 +264,13 @@ impl AppState {
|
||||
/// core 事件。
|
||||
pub fn apply_core(&mut self, event: CoreEvent) {
|
||||
match event {
|
||||
CoreEvent::InstancesChanged(instances) => self.instances = instances,
|
||||
CoreEvent::InstancesChanged(instances) => {
|
||||
self.instances = instances;
|
||||
// 收到快照 = 就绪(M302 三态的唯一就绪判据,见 `InstancePhase`)
|
||||
self.instances_phase = InstancePhase::Ready;
|
||||
}
|
||||
// M302:只发「没就绪」的两态(扫盘起跑 / 超时兜底);`Ready` 由上面那条隐含
|
||||
CoreEvent::InstancePhaseChanged(phase) => self.instances_phase = phase,
|
||||
CoreEvent::InstanceRuntimeChanged { id, runtime } => {
|
||||
if let Some(instance) = self.instances.iter_mut().find(|item| item.id == id) {
|
||||
instance.runtime = runtime;
|
||||
|
||||
Reference in new issue
Block a user