fix(notify): M298 r1 —— toast 退场腿接上触发点,四条 nit 一并收

复审 P2-1:进场腿自己续帧所以能跑,但进场结束 view_dirty 回 false,
3.6s 后 remaining_ms 掉进 150ms 退场窗口时没有任何代码重新置脏 ⇒ 那一段
收缩从不发生,expire 一抹胶囊整块消失(复审探针:120 拍后 progress 仍 1.0)。

- update/clock.rs:expire 之后用无副作用的 ToastTracks::needs_frame 置脏,
  落到既有的 if view_dirty { refresh_view() } 上采样;refresh_view 仍是门控,
  没有变成每帧无条件调用(静止态判据不变)。
- state/toast.rs:进出两条腿改成**同一个时钟**(寿命 = 首次看见时的
  remaining_ms,已活 = 寿命 - remaining),去掉 Instant 与节拍的二时钟错位;
  新增 needs_frame;新增两条真节拍用例(退场腿必被抽到 / 到期后不再续帧)。
- N1:装配失败与 spawn 失败也用 publish_before_start 的返回值填 session。
- N2:m298 用例注释改准(它抓不住删守卫,那条由 a_report_from_another_session 守)。
- N3:before_start 文档不再把两种来源都说成「进程没起来」。
- N4:refresh_view 末尾的死赋值 view_dirty = false 删掉。

红灯原文(摘掉那 3 行触发点,其余为最终代码):
  退场窗口内必须真的开始收(remaining=Some(40)ms, progress=1)
This commit is contained in:
AskaEth committed 2026-11-13 13:50:41 +08:00
1 parent aa25583806
commit 3b4f8bb997
6 files changed
+189 -57

No files matched your search

+5 -2
View File
@@ -146,8 +146,11 @@ fn a_report_from_another_session_is_never_reused() {
/// 没有会话的两个出口(装配失败 / spawn 失败)**照旧要弹**:那里空串是事实,守卫不该拦它。 /// 没有会话的两个出口(装配失败 / spawn 失败)**照旧要弹**:那里空串是事实,守卫不该拦它。
/// ///
/// 抓得住:为修「收尾失败被误判」而把守卫整条删掉 / 或把空串也编一个 id 出来(后者会让 /// 抓得住:把「没会话」也编一个 id 出来(那会让装配失败的现场指向一个不存在的会话);
/// 装配失败的现场指向一个不存在的会话)。 /// 也抓得住这条出口被新判据误伤(例如把「session 为空」当成「必须不弹」)。
///
/// **抓不住「把并发守卫整条删掉」**——复审的变异 C 实测:删掉守卫后本用例照样绿(它本来就
/// 走 session 为空的路径)。那条守卫由 `a_report_from_another_session_is_never_reused` 守。
#[test] #[test]
fn a_session_less_failure_still_pops() { fn a_session_less_failure_still_pops() {
let _slot = slot_guard(); let _slot = slot_guard();
+7 -6
View File
@@ -117,11 +117,13 @@ impl App {
if next_modal.is_some() || !motion::modal_closing() { if next_modal.is_some() || !motion::modal_closing() {
self.dialog_model = next_modal; self.dialog_model = next_modal;
} }
self.view_dirty = false;
self.refreshes += 1; self.refreshes += 1;
// M298:toast 的进/出场也要「每帧一次」——而 `refresh_view` 只在 `view_dirty` 时跑。 // M298:`view_dirty` 的**唯一**收尾赋值(旧写法先写 `false` 再被这里覆盖,是死赋值,
// 于是这里把「还有提示在动」翻译成**再续一帧**(`view_dirty` 重新置位);静止在位之后 // 复审 N4 指出后删掉)。语义:这一帧已经把模型重建完了,于是清脏;但 toast 若还在
// 它回到 `false`,与改造前一样不白重建(M192 的「空闲不烧帧」口径不变)。 // 进场 / 退场腿上就得**再续一帧**——`sync_toasts` 的返回值正好是「还有提示在动」。
// 静止在位之后它为 `false`,与改造前一样不白重建(M192 的「空闲不烧帧」口径不变)。
// 退场腿不是靠这里点亮的(进场结束就断了):那一下由 `update/clock.rs` 的
// `needs_frame` 判据在退场窗口到来的那一拍重新置脏。
self.view_dirty = self.sync_toasts(); self.view_dirty = self.sync_toasts();
} }
@@ -129,8 +131,7 @@ impl App {
/// ///
/// 返回 `true` = 还有提示在进/出场,这一帧要续。 /// 返回 `true` = 还有提示在进/出场,这一帧要续。
fn sync_toasts(&mut self) -> bool { fn sync_toasts(&mut self) -> bool {
let toasts = self.state.toasts.items(); self.toast_tracks.observe(self.state.toasts.items())
self.toast_tracks.observe(toasts, std::time::Instant::now())
} }
/// 收掉「被确认的世界已经不在快照里」的那个删除确认框(M247,复审 r1 P2-1)。 /// 收掉「被确认的世界已经不在快照里」的那个删除确认框(M247,复审 r1 P2-1)。
+150 -44
View File
@@ -7,23 +7,39 @@
//! 消失。再往帧泵里塞一条「toast 轨道」等于把**同一件事的时钟记两遍**,两边一旦不同步就是 //! 消失。再往帧泵里塞一条「toast 轨道」等于把**同一件事的时钟记两遍**,两边一旦不同步就是
//! 「提示还在、动画已经结束」。 //! 「提示还在、动画已经结束」。
//! //!
//! 所以这里只做一件帧泵不做的事:记住**每一条是什么时候出现的**(`born`),然后每拍按 toast //! ## 只有**一个**时钟:toast 自己的 `remaining_ms`
//! 自己的两个已知量采样进度——`born` 到现在的时长(进场)与 `remaining_ms`(退场)。 //!
//! 第一版(M298 r0)用 `Instant::now()` 算进场年龄、用 `remaining_ms` 算退场——两个时钟。
//! 复审 P2-1 的探针把它撞出来了:`Instant` 走**墙上时间**、`remaining_ms` 走**节拍时间**,
//! 于是「测试里连走 120 拍」和「真机上过了 4 秒」不是同一件事;更要紧的是它掩盖了真 bug
//! (退场腿没有触发点,见下)。
//!
//! 现在两条腿都从同一个量推:第一次看见这条提示时把 `remaining_ms` 记成它的**寿命**
//! (`life_ms` = TTL),此后 `已活 = life_ms - remaining_ms`。进场腿吃 `已活`,退场腿吃
//! `remaining_ms` —— 与 `expire` 抹掉它的那一刻天然对齐,没有第二份时间。
//!
//! 时长档与缓动档**复用模态那一对腿**([`motion::modal_spec`]):toast 与对话框同属 M3 的 //! 时长档与缓动档**复用模态那一对腿**([`motion::modal_spec`]):toast 与对话框同属 M3 的
//! 「用户刚做了一件事的回应」短档,没有第二条曲线可言。 //! 「用户刚做了一件事的回应」短档,没有第二条曲线可言。
//! //!
//! ## 为什么采样发生在 `update` 侧 //! ## 为什么采样发生在 `update` 侧,且退场还要**另外一个**置脏判据
//! //!
//! 与帧泵同一条纪律(`components::motion` 模块文档的五条契约):`view` 只采样、不起跑。这里 //! 与帧泵同一条纪律(`components::motion` 模块文档的五条契约):`view` 只采样、不起跑。这里的
//! 的 [`App::sync_toasts`](super::App) 在 [`App::refresh_view`](super::App::refresh_view) 里跑, //! [`App::sync_toasts`](super::App) 在 [`App::refresh_view`](super::App::refresh_view) 里跑,
//! 结果是「这一帧每条该缩放多少」——`view` 拿到的只是一份只读快照。 //! 结果是「这一帧每条该缩放多少」——`view` 拿到的只是一份只读快照。
//! //!
//! 但 `refresh_view` 只被 `view_dirty` 开门,而进场腿自己会续帧、退场腿**不会**:进场结束时
//! `view_dirty` 回到 `false`,等 3.6 秒后 `remaining_ms` 掉进退场窗口时没有任何代码把它重新
//! 置脏 ⇒ 150 ms 的收缩从不发生,`expire` 一抹胶囊就整块消失(复审 P2-1 实测:120 拍之后
//! `progress` 仍是 1.0)。所以 [`ToastTracks::needs_frame`] 是给 `update/clock.rs` 的**无副作用**
//! 判据:只要还有条目在退场窗口里,就把 `view_dirty` 点亮,让既有的
//! `if self.view_dirty { refresh_view() }` 去采样。**`refresh_view` 没有变成每帧无条件调用**,
//! 静止态(无提示 / 提示都在位)帧订阅判据与改造前一样(M192「空闲不烧帧」)。
//!
//! ## 关掉动效 = 一帧都不动 //! ## 关掉动效 = 一帧都不动
//! //!
//! `MotionScale::NONE`(截图通道 / CPU 档 / 系统「减少动效」)时进度**恒为 1.0**,且 //! `MotionScale::NONE`(截图通道 / CPU 档 / 系统「减少动效」)时进度**恒为 1.0**,
//! [`ToastTracks::observe`] 返回 `false` ⇒ 不会为了动画续帧。出图因此可复现(D30)。 //! [`ToastTracks::observe`] 与 [`ToastTracks::needs_frame`] 都返回 `false` ⇒ 不会为了动画续帧。
//! 出图因此可复现(D30)。
use std::time::Instant;
use unlucky_presenter::toast::{Toast, ToastId}; use unlucky_presenter::toast::{Toast, ToastId};
@@ -34,8 +50,8 @@ use crate::app::components::motion;
struct Entry { struct Entry {
/// toast id(`ToastCenter` 重新编号,进程内唯一)。 /// toast id(`ToastCenter` 重新编号,进程内唯一)。
id: ToastId, id: ToastId,
/// 第一次被看见的时刻。 /// 这条提示**总共活多久**:第一次被看见时 `remaining_ms` 的值(= `ToastCenter` 的 TTL)。
born: Instant, life_ms: u64,
/// 这一拍算出来的进度(`0` = 刚起手 / 正在退场尾声,`1` = 完全在位)。 /// 这一拍算出来的进度(`0` = 刚起手 / 正在退场尾声,`1` = 完全在位)。
progress: f32, progress: f32,
} }
@@ -49,30 +65,30 @@ pub(crate) struct ToastTracks {
impl ToastTracks { impl ToastTracks {
/// 这一拍动没动过(`false` = 全都静止在位,不必续帧)。 /// 这一拍动没动过(`false` = 全都静止在位,不必续帧)。
/// ///
/// **update 侧每拍调一次**:登记新出现的、忘掉已经消失的、重算每条进度。 /// **update 侧每拍调一次**:登记新出现的(记下寿命)、忘掉已经消失的、重算每条进度。
pub(crate) fn observe(&mut self, toasts: &[Toast], now: Instant) -> bool { pub(crate) fn observe(&mut self, toasts: &[Toast]) -> bool {
self.entries self.entries
.retain(|entry| toasts.iter().any(|toast| toast.id == entry.id)); .retain(|entry| toasts.iter().any(|toast| toast.id == entry.id));
let spec = motion::modal_spec(); let spec = motion::modal_spec();
let scale = motion::scale(); let scale = motion::scale();
let mut moving = false; let mut moving = false;
for toast in toasts { for toast in toasts {
let born = match self.entries.iter().find(|entry| entry.id == toast.id) { let life_ms = match self.entries.iter().find(|entry| entry.id == toast.id) {
Some(entry) => entry.born, Some(entry) => entry.life_ms,
None => { None => {
self.entries.push(Entry { self.entries.push(Entry {
id: toast.id, id: toast.id,
born: now, life_ms: toast.remaining_ms,
progress: 1.0, progress: 1.0,
}); });
now toast.remaining_ms
} }
}; };
// 关着动效:进度就是终值,且**不**算「在动」(不续帧、不出图差异)。 // 关着动效:进度就是终值,且**不**算「在动」(不续帧、不出图差异)。
let progress = if scale.is_off() { let progress = if scale.is_off() {
1.0 1.0
} else { } else {
let (progress, in_flight) = sample(toast, born, now, &spec, scale); let (progress, in_flight) = sample(toast, life_ms, &spec, scale);
moving |= in_flight; moving |= in_flight;
progress progress
}; };
@@ -90,23 +106,39 @@ impl ToastTracks {
.find(|entry| entry.id == id) .find(|entry| entry.id == id)
.map_or(1.0, |entry| entry.progress) .map_or(1.0, |entry| entry.progress)
} }
/// 还有没有条目落在**退场窗口**里(`update/clock.rs` 唯一的置脏判据,**无副作用**)。
///
/// 判据与 [`sample`] 里退场腿那一项同一个算式:`remaining_ms < exit` 腿的时长。
/// 它只看**已登记**的条目——`observe` 还没跑过的那条不算(那时 `progress` 也是终值)。
pub(crate) fn needs_frame(&self, toasts: &[Toast]) -> bool {
let scale = motion::scale();
if scale.is_off() {
return false;
}
let exit_ms = scale.ms(motion::modal_spec().exit.duration).max(1.0);
self.entries.iter().any(|entry| {
toasts
.iter()
.any(|toast| toast.id == entry.id && (toast.remaining_ms as f32) < exit_ms)
})
}
} }
/// 一条 toast 的进 / 出采样(纯函数,测试直接打这里)。 /// 一条 toast 的进 / 出采样(纯函数,测试直接打这里)。
/// ///
/// 进场:`born` 之后走完 `enter` 腿;退场:`remaining_ms` 掉进 `exit` 腿的时长里之后走到 0。 /// 进场:已活时长走完 `enter` 腿;退场:`remaining_ms` 掉进 `exit` 腿的时长里之后走到 0。
/// 两条腿同时跑(进场还没完就到期的极端情况取**小的那个**,于是它一开始就在退场,不会闪一下 /// 两条腿同时跑(进场还没完就到期的极端情况取**小的那个**,于是它一开始就在退场,不会闪一下
/// 全尺寸再消失)。 /// 全尺寸再消失)。
fn sample( fn sample(
toast: &Toast, toast: &Toast,
born: Instant, life_ms: u64,
now: Instant,
spec: &motion::ModalSpec, spec: &motion::ModalSpec,
scale: unlucky_design::motion::MotionScale, scale: unlucky_design::motion::MotionScale,
) -> (f32, bool) { ) -> (f32, bool) {
let enter_ms = scale.ms(spec.enter.duration).max(1.0); let enter_ms = scale.ms(spec.enter.duration).max(1.0);
let exit_ms = scale.ms(spec.exit.duration).max(1.0); let exit_ms = scale.ms(spec.exit.duration).max(1.0);
let lived_ms = now.saturating_duration_since(born).as_secs_f32() * 1000.0; let lived_ms = life_ms.saturating_sub(toast.remaining_ms) as f32;
let enter_t = (lived_ms / enter_ms).clamp(0.0, 1.0); let enter_t = (lived_ms / enter_ms).clamp(0.0, 1.0);
let exit_t = (1.0 - toast.remaining_ms as f32 / exit_ms).clamp(0.0, 1.0); let exit_t = (1.0 - toast.remaining_ms as f32 / exit_ms).clamp(0.0, 1.0);
let progress = spec let progress = spec
@@ -123,14 +155,47 @@ mod tests {
//! **进/出两条腿的数值取证**:起点、终点、单调、以及「不动就一帧都不续」。 //! **进/出两条腿的数值取证**:起点、终点、单调、以及「不动就一帧都不续」。
//! //!
//! 抓得住:进场方向做反(`progress` 从 1 掉到 0 即红)、退场没接上(`remaining_ms` 掉了 //! 抓得住:进场方向做反(`progress` 从 1 掉到 0 即红)、退场没接上(`remaining_ms` 掉了
//! 进度却还是 1)、关掉动效仍然报「在动」(`NONE` 下 `observe` 必须返回 `false`)。 //! 进度却还是 1)、关掉动效仍然报「在动」(`NONE` 下 `observe` / `needs_frame` 必须为假)、
//! **退场腿在产品路径上没有触发点**(两条 `ticking_app` 用例走真节拍)。
//! 抓不住:**观感**(截图见 `DevDocs/artifacts/m298/`)。 //! 抓不住:**观感**(截图见 `DevDocs/artifacts/m298/`)。
use super::*; use super::*;
use std::time::Duration; use std::time::Duration;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::motion::MotionScale; use unlucky_design::motion::MotionScale;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
use unlucky_presenter::toast::ToastKind; use unlucky_presenter::toast::ToastKind;
use crate::app::state::App;
use crate::cli::Options;
use crate::theme::Families;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 一拍的时长(= `App::TICK_MS`,`update/clock.rs` 的节拍口径)。
const TICK: Duration = Duration::from_millis(33);
/// 注入式 `App`(不碰磁盘、mock 后端),并把动效钉在 `STANDARD`。
///
/// 这两条用例走的是**真节拍**(`update/clock.rs` 的 `advance`),不是在测试里直接喂
/// `observe`——复审 P2-1 漏掉的正是这一层:`observe` 自己算得再对,没人抽它就是没播。
fn ticking_app() -> App {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
fn toast(id: ToastId, remaining_ms: u64) -> Toast { fn toast(id: ToastId, remaining_ms: u64) -> Toast {
Toast { Toast {
id, id,
@@ -149,35 +214,27 @@ mod tests {
#[test] #[test]
fn a_fresh_toast_enters_from_zero_and_stays_at_one() { fn a_fresh_toast_enters_from_zero_and_stays_at_one() {
let mut tracks = tracks(MotionScale::STANDARD); let mut tracks = tracks(MotionScale::STANDARD);
let now = Instant::now(); // 第一次被看见 = 寿命登记当拍,进度在起点。
let items = [toast(1, 4_000)]; assert!(tracks.observe(&[toast(1, 4_000)]), "进场这一段必须在动");
// 第一拍:刚登记 = 进场起点。
assert!(tracks.observe(&items, now), "进场这一段必须在动");
assert!( assert!(
tracks.progress(1) < 0.05, tracks.progress(1) < 0.05,
"刚出现必须是进场起点,实测 {}", "刚出现必须是进场起点,实测 {}",
tracks.progress(1) tracks.progress(1)
); );
// 进场腿走完之后:停在 1.0,且不再报「在动」。 // 已活 400 ms(进场腿 200 ms)⇒ 停在 1.0,且不再报「在动」。
let later = now + Duration::from_millis(400); assert!(!tracks.observe(&[toast(1, 3_600)]), "进场走完就不该再续帧");
assert!(!tracks.observe(&items, later), "进场走完就不该再续帧");
assert_eq!(tracks.progress(1), 1.0, "在位就必须是满进度"); assert_eq!(tracks.progress(1), 1.0, "在位就必须是满进度");
} }
#[test] #[test]
fn the_last_moments_play_the_exit_leg_down_to_zero() { fn the_last_moments_play_the_exit_leg_down_to_zero() {
let mut tracks = tracks(MotionScale::STANDARD); let mut tracks = tracks(MotionScale::STANDARD);
let now = Instant::now(); tracks.observe(&[toast(7, 4_000)]);
tracks.observe(&[toast(7, 4_000)], now); tracks.observe(&[toast(7, 3_600)]);
let settled = now + Duration::from_millis(400);
tracks.observe(&[toast(7, 4_000)], settled);
assert_eq!(tracks.progress(7), 1.0); assert_eq!(tracks.progress(7), 1.0);
// 寿命只剩 60 ms(小于退场腿 150 ms)⇒ 进度必须已经掉下来且仍在动。 // 寿命只剩 60 ms(小于退场腿 150 ms)⇒ 进度必须已经掉下来且仍在动。
assert!( assert!(tracks.observe(&[toast(7, 60)]), "退场这一段必须在动");
tracks.observe(&[toast(7, 60)], settled),
"退场这一段必须在动"
);
let mid = tracks.progress(7); let mid = tracks.progress(7);
assert!( assert!(
mid < 1.0 && mid > 0.0, mid < 1.0 && mid > 0.0,
@@ -186,27 +243,76 @@ mod tests {
// 到期当拍(`remaining_ms` 归零):进度必须是 0 —— 它就在这一刻被 `expire` 抹掉, // 到期当拍(`remaining_ms` 归零):进度必须是 0 —— 它就在这一刻被 `expire` 抹掉,
// 中间不许留一帧「还满着」。 // 中间不许留一帧「还满着」。
tracks.observe(&[toast(7, 0)], settled); tracks.observe(&[toast(7, 0)]);
assert!(tracks.progress(7) < 0.05, "到期当拍必须归零"); assert!(tracks.progress(7) < 0.05, "到期当拍必须归零");
} }
#[test] #[test]
fn motion_off_is_bit_exact_still_and_never_asks_for_frames() { fn motion_off_is_bit_exact_still_and_never_asks_for_frames() {
let mut tracks = tracks(MotionScale::NONE); let mut tracks = tracks(MotionScale::NONE);
let now = Instant::now();
assert!( assert!(
!tracks.observe(&[toast(1, 4_000)], now), !tracks.observe(&[toast(1, 4_000)]),
"关掉动效时一帧都不该续(截图 / CPU 档的可复现前提)" "关掉动效时一帧都不该续(截图 / CPU 档的可复现前提)"
); );
assert_eq!(tracks.progress(1), 1.0, "NONE 下进度恒为终值"); assert_eq!(tracks.progress(1), 1.0, "NONE 下进度恒为终值");
assert!(
!tracks.needs_frame(&[toast(1, 40)]),
"NONE 下连退场窗口都不该点灯"
);
}
/// **复审 P2-1 的红灯**:退场腿在产品路径上必须真的被抽到。
///
/// 抓得住:`observe` 只在 `refresh_view` 里跑、而 `refresh_view` 只被 `view_dirty` 开门 ⇒
/// 进场腿自己续帧(所以进场能跑),进场结束后没人再置脏,**退场那 150 ms 从不发生**,
/// `expire` 把条目一抹胶囊就整块消失(复审探针实测:120 拍之后 `progress` 仍是 1.0)。
/// 抓不住:观感(静态图看不出一段没播的动画),也抓不住「置脏了但没画出来」。
#[test]
fn the_tick_clock_actually_carries_the_exit_leg() {
let mut app = ticking_app();
let id = app.state.toasts.push(ToastKind::Info, "已取消安装");
// 进场腿:这几拍由 `observe` 自己续帧(所以它本来就能跑)。
for _ in 0..10 {
app.advance(TICK);
}
assert_eq!(app.toast_tracks.progress(id), 1.0, "进场走完应当停在满进度");
// 之后**没有任何事件**,只让节拍继续走:4 s TTL - 10 拍 ≈ 还剩 3.6 s。
for _ in 0..110 {
app.advance(TICK);
}
let remaining = app.state.toasts.items().first().map(|it| it.remaining_ms);
let progress = app.toast_tracks.progress(id);
assert!(
remaining.is_some_and(|ms| ms < 150),
"这一拍应当已经落在 150 ms 的退场腿里(remaining={remaining:?}ms)"
);
assert!(
progress < 1.0,
"退场窗口内必须真的开始收(remaining={remaining:?}ms, progress={progress})"
);
}
/// 到期之后**不许**继续置脏:否则退场窗口的修复会变成「永不停止的每帧重建」。
#[test]
fn an_expired_toast_stops_asking_for_frames() {
let mut app = ticking_app();
app.state.toasts.push(ToastKind::Info, "已取消安装");
for _ in 0..130 {
app.advance(TICK);
}
assert!(app.state.toasts.is_empty(), "TTL 用完必须被 expire 抹掉");
for _ in 0..5 {
app.advance(TICK);
}
assert!(!app.view_dirty, "没有提示时不许每帧置脏(M192 空闲不烧帧)");
assert!(!app.needs_frames(), "静止态不该订阅帧");
} }
#[test] #[test]
fn a_vanished_toast_is_forgotten() { fn a_vanished_toast_is_forgotten() {
let mut tracks = tracks(MotionScale::STANDARD); let mut tracks = tracks(MotionScale::STANDARD);
let now = Instant::now(); tracks.observe(&[toast(1, 4_000)]);
tracks.observe(&[toast(1, 4_000)], now); assert!(tracks.observe(&[toast(2, 4_000)]), "新的那条在进场");
assert!(tracks.observe(&[toast(2, 4_000)], now), "新的那条在进场");
assert_eq!(tracks.progress(1), 1.0, "已消失的按在位处理(不留半截)"); assert_eq!(tracks.progress(1), 1.0, "已消失的按在位处理(不留半截)");
assert!( assert!(
!tracks.entries.iter().any(|entry| entry.id == 1), !tracks.entries.iter().any(|entry| entry.id == 1),
+12
View File
@@ -37,6 +37,18 @@ impl App {
self.state self.state
.toasts .toasts
.expire(u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)); .expire(u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX));
// M298 r1(复审 P2-1):toast **退场腿**的唯一触发点。
//
// 进场腿自己续帧(`observe` 返回 moving ⇒ `refresh_view` 末尾重新置脏),但进场走完
// 之后 `view_dirty` 是假的;等 3.6 s 后 `remaining_ms` 掉进退场窗口时**没有任何代码**
// 把它重新置脏 ⇒ 那 150 ms 的收缩从不发生,`expire` 一抹胶囊就整块消失。
//
// 判据无副作用(`ToastTracks::needs_frame` 只读),置脏之后落到下面**既有的**
// `if self.view_dirty { refresh_view() }` 上采样 —— `refresh_view` 没有变成每帧无条件
// 调用,静止态(无提示 / 提示都在位)本行为假,M192「空闲不烧帧」不受影响。
if self.toast_tracks.needs_frame(self.state.toasts.items()) {
self.view_dirty = true;
}
// 设备码/授权码的倒计时(M114):等待态每秒都要重画一次,否则「剩 900s」永远不动, // 设备码/授权码的倒计时(M114):等待态每秒都要重画一次,否则「剩 900s」永远不动,
// 用户会以为卡死了——这正是任务里「不得只转圈」的那一条。 // 用户会以为卡死了——这正是任务里「不得只转圈」的那一条。
+9 -2
View File
@@ -241,8 +241,15 @@ impl CrashReport {
/// 「别人的报告」而**静默不弹**。归因必须由调用方给(只有它知道是哪一场),本函数只负责 /// 「别人的报告」而**静默不弹**。归因必须由调用方给(只有它知道是哪一场),本函数只负责
/// 把它原样记进报告——两边同源,守卫才可能判对。 /// 把它原样记进报告——两边同源,守卫才可能判对。
/// ///
/// 这种失败最常见的形态就是「找不到 crash-report」——游戏根本没被执行到,所以 /// 两个用法的**证据形态不同,判据同一个**([`CrashReport::evidence`] 只看有没有
/// [`Evidence`] 落到 [`Evidence::Nothing`],界面必须说「游戏还没来得及写日志」。 /// crash-report / 有没有捕获输出,**不**看 `stage`):
///
/// | 用法 | 常见形态 | 界面该说什么 |
/// |---|---|---|
/// | `session: None`(装配 / spawn 失败) | 「找不到 crash-report」,游戏根本没被执行到 ⇒ [`Evidence::Nothing`] | 「游戏还没来得及写日志」 |
/// | `session: Some`(收尾失败) | 进程起来过、事件流有落点,只是拿不到 `SessionHandle` | 按真实证据说(多半有捕获输出) |
///
/// 别把这张表读成「本函数只描述没起来的那种」——M298 r0 的注释正是这么写的,复审 N3 抓到。
#[must_use] #[must_use]
pub fn before_start( pub fn before_start(
ctx: &ReportContext, ctx: &ReportContext,
+6 -3
View File
@@ -82,10 +82,13 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
Err(failure) => { Err(failure) => {
let line = format!("instance={} 装配失败:{}", target.id, failure.detail); let line = format!("instance={} 装配失败:{}", target.id, failure.detail);
launcher_log::record("launch", &line); launcher_log::record("launch", &line);
// 归因是 `None`(进程没起来),但**照样用返回值**填 DTO:单一来源是结构,
// 不是「两边碰巧都写空串」(复审 N1)。
let session =
publish_before_start(&report, None, failure.stage.slug(), &failure.detail); publish_before_start(&report, None, failure.stage.slug(), &failure.detail);
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished { let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id, id,
session: String::new(), session,
outcome: LogOutcome::Crashed, outcome: LogOutcome::Crashed,
exit_code: None, exit_code: None,
uptime_ms: 0, uptime_ms: 0,
@@ -123,10 +126,10 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
"launch", "launch",
&format!("instance={} spawn 失败:{error}", target.id), &format!("instance={} spawn 失败:{error}", target.id),
); );
publish_before_start(&report, None, "spawn", &failure.detail); let session = publish_before_start(&report, None, "spawn", &failure.detail);
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished { let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
id, id,
session: String::new(), session,
outcome: LogOutcome::Crashed, outcome: LogOutcome::Crashed,
exit_code: None, exit_code: None,
uptime_ms: 0, uptime_ms: 0,