feat(host): M302 实例列表刷新搬出 UI 线程(扫盘进后台 + 代次丢弃过期结果)
`refresh_instances()` 以前把「扫盘」与「盖回运行时态」写在一起,而 `dispatch(RefreshAll)`
那一支是唯一没走 `job::spawn` 的(邻座 ListVersions / ListLoaderVersions 都走了)⇒ 同步跑在
UI 线程:官方根 200 版本约 236 ms、叠加 10 份外部档案 1.35 s(仓库内旧实测 M297)。
两半分家:
- `game::instances(&env)` 是纯 env 函数 ⇒ 搬进 `job::spawn("instances", …)`,回程是新载荷
`HostTask::Instances { epoch, instances, elapsed_ms }`;
- 「按 `launches/pending/failed` 覆写 Running/Busy/Failed」要读 host 自己的表 ⇒ 留在 host 线程,
挪到结果回来那一刻(`handle::apply_instances`),结论与之前逐字一致。
顺序:每次刷新领一个只增不减的代次(`game::Scan`),只采纳当代次那份。并发两次刷新 / 扫描途中
删实例 / 收尾触发第三次这三种交错下,先发后到的扫描一律丢(不发事件:清空再重填只是把卡顿换成
白屏)。不做线程取消 —— 扫描是纯读,没有值得取消的东西,丢结果比掐线程安全。
可观测:每次采纳打一行 `[instances] N 个实例 · 扫盘 X ms · epoch E · 丢旧代次 D 份`,
丢弃也各打一行;账本 `game::Scan` 可直接断言(实例数 / 耗时 / 丢包数)。
判据(都真构造:真临时根 + 真 profiles.json + 真线程,只有交付顺序是摆的):
- `a_refresh_returns_before_the_scan_is_done`(改回 inline 即红)
- `two_refreshes_in_flight_publish_only_the_newest`
- `an_older_scan_cannot_resurrect_what_a_newer_one_saw_deleted`
- `a_launch_end_refresh_beats_the_two_that_were_in_flight`
- app 侧 `a_refresh_in_flight_keeps_the_list_and_does_not_pin_the_clock`(不清空 + M298 的
view_dirty 不永久置真)
- `launch_state_tests` 的 5 处 `refresh_instances()` 后补一行 `pump_instances()`(断言一字未动)
This commit is contained in:
1 parent
38fbd2a778
commit
a0413a5f94
8 files changed
+753
-28
No files matched your search
@@ -181,7 +181,7 @@ impl App {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_presenter::dto::LaunchArgsView;
|
||||
use unlucky_presenter::dto::{AppCommand, AppEvent, Backend, CoreEvent, LaunchArgsView};
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
use crate::app::state::App;
|
||||
@@ -267,7 +267,85 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 表单没绑定到当前实例时不接编辑(否则草稿会写到别的实例上)。
|
||||
/// **M302:刷新在飞时列表不清空,快照落地后时钟不被钉住**。
|
||||
///
|
||||
/// 现场:扫盘搬进后台线程之后,「派发刷新 → 结果回来」之间有一小段真空。这段真空里界面
|
||||
/// 只有两条路:**留着旧列表**(用户看到的是「还在,但还没变」)或者**清空再重填**(用户
|
||||
/// 看到的是一屏白/空态)。M302 要的是前者;后者等于把卡顿换成闪屏。
|
||||
///
|
||||
/// 第二件事是 M298 的型态:`view_dirty` 一旦永久为真,`advance` 就每拍无条件 `refresh_view`
|
||||
/// (整页视图模型重建),而「快照落地」这条路最容易被写成「只置脏不重建」。
|
||||
///
|
||||
/// 抓得住:① 谁在派发刷新的那一刻清空 `state.instances`;② 谁把 `InstancesChanged` 的落地
|
||||
/// 路径写成「置脏不落地」或者让 `refresh_view` 收尾不再复位 `view_dirty`。
|
||||
/// 抓不住:真线程的时序与竞争(那在 presenter 的 `host::game::refresh::tests` 交错用例里);
|
||||
/// 也抓不住「扫盘真要多久」(那是真实测量,不是单测)。
|
||||
#[test]
|
||||
fn a_refresh_in_flight_keeps_the_list_and_does_not_pin_the_clock() {
|
||||
/// 只喂首屏快照、派发命令一律不回答的后端(= 「刷新还在飞」)。
|
||||
#[derive(Debug)]
|
||||
struct RecorderBackend {
|
||||
seeded: Vec<AppEvent>,
|
||||
}
|
||||
|
||||
impl Backend for RecorderBackend {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
std::mem::take(&mut self.seeded)
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, _command: AppCommand) {}
|
||||
}
|
||||
|
||||
let mut app = App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(RecorderBackend {
|
||||
seeded: vec![AppEvent::Core(CoreEvent::InstancesChanged(
|
||||
unlucky_presenter::mock_data::mock_instances(),
|
||||
))],
|
||||
}),
|
||||
SettingsBridge::memory_only(),
|
||||
);
|
||||
// 前提:首屏快照已落地、入场动效已经跑完(否则 `needs_frames` 为真是动效的锅,不是刷新的)
|
||||
app.pump();
|
||||
while crate::app::components::motion::needs_frames() {
|
||||
crate::app::components::motion::advance_by(crate::app::components::motion::FRAME_MS);
|
||||
}
|
||||
let tiles = app.state.tiles().len();
|
||||
assert!(tiles > 0, "前提:首屏该有实例");
|
||||
|
||||
// 刷新在飞:命令出去了,后端这一拍一个字都不回
|
||||
app.backend.dispatch(AppCommand::RefreshAll);
|
||||
app.pump();
|
||||
assert_eq!(
|
||||
app.state.tiles().len(),
|
||||
tiles,
|
||||
"刷新在飞时列表被清空了(清空再重填 = 把卡顿换成闪屏)"
|
||||
);
|
||||
assert!(
|
||||
!app.view_dirty,
|
||||
"刷新在飞时 `view_dirty` 不许留在真上(M298:每拍重建整页视图模型)"
|
||||
);
|
||||
assert!(
|
||||
!app.needs_frames(),
|
||||
"刷新在飞时不该多出帧节拍(真要等回程,走派发后的 3 s 观察窗,不是永久订阅)"
|
||||
);
|
||||
|
||||
// 回程到了:整份替换(不是追加),并且这一拍就把脏标收回去
|
||||
app.backend = Box::new(RecorderBackend {
|
||||
seeded: vec![AppEvent::Core(CoreEvent::InstancesChanged(
|
||||
unlucky_presenter::mock_data::mock_instances(),
|
||||
))],
|
||||
});
|
||||
app.pump();
|
||||
assert_eq!(app.state.tiles().len(), tiles, "快照是整体替换");
|
||||
assert!(
|
||||
!app.view_dirty,
|
||||
"快照落地之后 `view_dirty` 必须被 `refresh_view` 收回去(M298)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_are_ignored_when_the_form_is_bound_elsewhere() {
|
||||
let mut app = app_with_form();
|
||||
|
||||
Reference in new issue
Block a user