From 3b4f8bb9974cac7bb8a33c8210e89a3ef000cd1c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 13 Nov 2026 13:50:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(notify):=20M298=20r1=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20toast=20=E9=80=80=E5=9C=BA=E8=85=BF=E6=8E=A5=E4=B8=8A?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E7=82=B9=EF=BC=8C=E5=9B=9B=E6=9D=A1=20nit=20?= =?UTF-8?q?=E4=B8=80=E5=B9=B6=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 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) --- crates/app-iced/src/app/dialog/tests/m298.rs | 7 +- crates/app-iced/src/app/state/refresh.rs | 13 +- crates/app-iced/src/app/state/toast.rs | 194 ++++++++++++++----- crates/app-iced/src/app/update/clock.rs | 12 ++ crates/core/src/launch/crash_report.rs | 11 +- crates/presenter/src/host/launch.rs | 11 +- 6 files changed, 190 insertions(+), 58 deletions(-) diff --git a/crates/app-iced/src/app/dialog/tests/m298.rs b/crates/app-iced/src/app/dialog/tests/m298.rs index b68df35..b6bf82d 100644 --- a/crates/app-iced/src/app/dialog/tests/m298.rs +++ b/crates/app-iced/src/app/dialog/tests/m298.rs @@ -146,8 +146,11 @@ fn a_report_from_another_session_is_never_reused() { /// 没有会话的两个出口(装配失败 / spawn 失败)**照旧要弹**:那里空串是事实,守卫不该拦它。 /// -/// 抓得住:为修「收尾失败被误判」而把守卫整条删掉 / 或把空串也编一个 id 出来(后者会让 -/// 装配失败的现场指向一个不存在的会话)。 +/// 抓得住:把「没会话」也编一个 id 出来(那会让装配失败的现场指向一个不存在的会话); +/// 也抓得住这条出口被新判据误伤(例如把「session 为空」当成「必须不弹」)。 +/// +/// **抓不住「把并发守卫整条删掉」**——复审的变异 C 实测:删掉守卫后本用例照样绿(它本来就 +/// 走 session 为空的路径)。那条守卫由 `a_report_from_another_session_is_never_reused` 守。 #[test] fn a_session_less_failure_still_pops() { let _slot = slot_guard(); diff --git a/crates/app-iced/src/app/state/refresh.rs b/crates/app-iced/src/app/state/refresh.rs index e69536e..eef5880 100644 --- a/crates/app-iced/src/app/state/refresh.rs +++ b/crates/app-iced/src/app/state/refresh.rs @@ -117,11 +117,13 @@ impl App { if next_modal.is_some() || !motion::modal_closing() { self.dialog_model = next_modal; } - self.view_dirty = false; self.refreshes += 1; - // M298:toast 的进/出场也要「每帧一次」——而 `refresh_view` 只在 `view_dirty` 时跑。 - // 于是这里把「还有提示在动」翻译成**再续一帧**(`view_dirty` 重新置位);静止在位之后 - // 它回到 `false`,与改造前一样不白重建(M192 的「空闲不烧帧」口径不变)。 + // M298:`view_dirty` 的**唯一**收尾赋值(旧写法先写 `false` 再被这里覆盖,是死赋值, + // 复审 N4 指出后删掉)。语义:这一帧已经把模型重建完了,于是清脏;但 toast 若还在 + // 进场 / 退场腿上就得**再续一帧**——`sync_toasts` 的返回值正好是「还有提示在动」。 + // 静止在位之后它为 `false`,与改造前一样不白重建(M192 的「空闲不烧帧」口径不变)。 + // 退场腿不是靠这里点亮的(进场结束就断了):那一下由 `update/clock.rs` 的 + // `needs_frame` 判据在退场窗口到来的那一拍重新置脏。 self.view_dirty = self.sync_toasts(); } @@ -129,8 +131,7 @@ impl App { /// /// 返回 `true` = 还有提示在进/出场,这一帧要续。 fn sync_toasts(&mut self) -> bool { - let toasts = self.state.toasts.items(); - self.toast_tracks.observe(toasts, std::time::Instant::now()) + self.toast_tracks.observe(self.state.toasts.items()) } /// 收掉「被确认的世界已经不在快照里」的那个删除确认框(M247,复审 r1 P2-1)。 diff --git a/crates/app-iced/src/app/state/toast.rs b/crates/app-iced/src/app/state/toast.rs index 3731e54..e9f896b 100644 --- a/crates/app-iced/src/app/state/toast.rs +++ b/crates/app-iced/src/app/state/toast.rs @@ -7,23 +7,39 @@ //! 消失。再往帧泵里塞一条「toast 轨道」等于把**同一件事的时钟记两遍**,两边一旦不同步就是 //! 「提示还在、动画已经结束」。 //! -//! 所以这里只做一件帧泵不做的事:记住**每一条是什么时候出现的**(`born`),然后每拍按 toast -//! 自己的两个已知量采样进度——`born` 到现在的时长(进场)与 `remaining_ms`(退场)。 +//! ## 只有**一个**时钟:toast 自己的 `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 的 //! 「用户刚做了一件事的回应」短档,没有第二条曲线可言。 //! -//! ## 为什么采样发生在 `update` 侧 +//! ## 为什么采样发生在 `update` 侧,且退场还要**另外一个**置脏判据 //! -//! 与帧泵同一条纪律(`components::motion` 模块文档的五条契约):`view` 只采样、不起跑。这里 -//! 的 [`App::sync_toasts`](super::App) 在 [`App::refresh_view`](super::App::refresh_view) 里跑, +//! 与帧泵同一条纪律(`components::motion` 模块文档的五条契约):`view` 只采样、不起跑。这里的 +//! [`App::sync_toasts`](super::App) 在 [`App::refresh_view`](super::App::refresh_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**,且 -//! [`ToastTracks::observe`] 返回 `false` ⇒ 不会为了动画续帧。出图因此可复现(D30)。 - -use std::time::Instant; +//! `MotionScale::NONE`(截图通道 / CPU 档 / 系统「减少动效」)时进度**恒为 1.0**, +//! [`ToastTracks::observe`] 与 [`ToastTracks::needs_frame`] 都返回 `false` ⇒ 不会为了动画续帧。 +//! 出图因此可复现(D30)。 use unlucky_presenter::toast::{Toast, ToastId}; @@ -34,8 +50,8 @@ use crate::app::components::motion; struct Entry { /// toast id(`ToastCenter` 重新编号,进程内唯一)。 id: ToastId, - /// 第一次被看见的时刻。 - born: Instant, + /// 这条提示**总共活多久**:第一次被看见时 `remaining_ms` 的值(= `ToastCenter` 的 TTL)。 + life_ms: u64, /// 这一拍算出来的进度(`0` = 刚起手 / 正在退场尾声,`1` = 完全在位)。 progress: f32, } @@ -49,30 +65,30 @@ pub(crate) struct ToastTracks { impl ToastTracks { /// 这一拍动没动过(`false` = 全都静止在位,不必续帧)。 /// - /// **update 侧每拍调一次**:登记新出现的、忘掉已经消失的、重算每条进度。 - pub(crate) fn observe(&mut self, toasts: &[Toast], now: Instant) -> bool { + /// **update 侧每拍调一次**:登记新出现的(记下寿命)、忘掉已经消失的、重算每条进度。 + pub(crate) fn observe(&mut self, toasts: &[Toast]) -> bool { self.entries .retain(|entry| toasts.iter().any(|toast| toast.id == entry.id)); let spec = motion::modal_spec(); let scale = motion::scale(); let mut moving = false; for toast in toasts { - let born = match self.entries.iter().find(|entry| entry.id == toast.id) { - Some(entry) => entry.born, + let life_ms = match self.entries.iter().find(|entry| entry.id == toast.id) { + Some(entry) => entry.life_ms, None => { self.entries.push(Entry { id: toast.id, - born: now, + life_ms: toast.remaining_ms, progress: 1.0, }); - now + toast.remaining_ms } }; // 关着动效:进度就是终值,且**不**算「在动」(不续帧、不出图差异)。 let progress = if scale.is_off() { 1.0 } else { - let (progress, in_flight) = sample(toast, born, now, &spec, scale); + let (progress, in_flight) = sample(toast, life_ms, &spec, scale); moving |= in_flight; progress }; @@ -90,23 +106,39 @@ impl ToastTracks { .find(|entry| entry.id == id) .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 的进 / 出采样(纯函数,测试直接打这里)。 /// -/// 进场:`born` 之后走完 `enter` 腿;退场:`remaining_ms` 掉进 `exit` 腿的时长里之后走到 0。 +/// 进场:已活时长走完 `enter` 腿;退场:`remaining_ms` 掉进 `exit` 腿的时长里之后走到 0。 /// 两条腿同时跑(进场还没完就到期的极端情况取**小的那个**,于是它一开始就在退场,不会闪一下 /// 全尺寸再消失)。 fn sample( toast: &Toast, - born: Instant, - now: Instant, + life_ms: u64, spec: &motion::ModalSpec, scale: unlucky_design::motion::MotionScale, ) -> (f32, bool) { let enter_ms = scale.ms(spec.enter.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 exit_t = (1.0 - toast.remaining_ms as f32 / exit_ms).clamp(0.0, 1.0); let progress = spec @@ -123,14 +155,47 @@ mod tests { //! **进/出两条腿的数值取证**:起点、终点、单调、以及「不动就一帧都不续」。 //! //! 抓得住:进场方向做反(`progress` 从 1 掉到 0 即红)、退场没接上(`remaining_ms` 掉了 - //! 进度却还是 1)、关掉动效仍然报「在动」(`NONE` 下 `observe` 必须返回 `false`)。 + //! 进度却还是 1)、关掉动效仍然报「在动」(`NONE` 下 `observe` / `needs_frame` 必须为假)、 + //! **退场腿在产品路径上没有触发点**(两条 `ticking_app` 用例走真节拍)。 //! 抓不住:**观感**(截图见 `DevDocs/artifacts/m298/`)。 use super::*; use std::time::Duration; + use unlucky_design::color::SchemeFlavor; use unlucky_design::motion::MotionScale; + use unlucky_presenter::mock::MockBackend; + use unlucky_presenter::settings_bridge::SettingsBridge; 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 { Toast { id, @@ -149,35 +214,27 @@ mod tests { #[test] fn a_fresh_toast_enters_from_zero_and_stays_at_one() { let mut tracks = tracks(MotionScale::STANDARD); - let now = Instant::now(); - let items = [toast(1, 4_000)]; - // 第一拍:刚登记 = 进场起点。 - assert!(tracks.observe(&items, now), "进场这一段必须在动"); + // 第一次被看见 = 寿命登记当拍,进度在起点。 + assert!(tracks.observe(&[toast(1, 4_000)]), "进场这一段必须在动"); assert!( tracks.progress(1) < 0.05, "刚出现必须是进场起点,实测 {}", tracks.progress(1) ); - // 进场腿走完之后:停在 1.0,且不再报「在动」。 - let later = now + Duration::from_millis(400); - assert!(!tracks.observe(&items, later), "进场走完就不该再续帧"); + // 已活 400 ms(进场腿 200 ms)⇒ 停在 1.0,且不再报「在动」。 + assert!(!tracks.observe(&[toast(1, 3_600)]), "进场走完就不该再续帧"); assert_eq!(tracks.progress(1), 1.0, "在位就必须是满进度"); } #[test] fn the_last_moments_play_the_exit_leg_down_to_zero() { let mut tracks = tracks(MotionScale::STANDARD); - let now = Instant::now(); - tracks.observe(&[toast(7, 4_000)], now); - let settled = now + Duration::from_millis(400); - tracks.observe(&[toast(7, 4_000)], settled); + tracks.observe(&[toast(7, 4_000)]); + tracks.observe(&[toast(7, 3_600)]); assert_eq!(tracks.progress(7), 1.0); // 寿命只剩 60 ms(小于退场腿 150 ms)⇒ 进度必须已经掉下来且仍在动。 - assert!( - tracks.observe(&[toast(7, 60)], settled), - "退场这一段必须在动" - ); + assert!(tracks.observe(&[toast(7, 60)]), "退场这一段必须在动"); let mid = tracks.progress(7); assert!( mid < 1.0 && mid > 0.0, @@ -186,27 +243,76 @@ mod tests { // 到期当拍(`remaining_ms` 归零):进度必须是 0 —— 它就在这一刻被 `expire` 抹掉, // 中间不许留一帧「还满着」。 - tracks.observe(&[toast(7, 0)], settled); + tracks.observe(&[toast(7, 0)]); assert!(tracks.progress(7) < 0.05, "到期当拍必须归零"); } #[test] fn motion_off_is_bit_exact_still_and_never_asks_for_frames() { let mut tracks = tracks(MotionScale::NONE); - let now = Instant::now(); assert!( - !tracks.observe(&[toast(1, 4_000)], now), + !tracks.observe(&[toast(1, 4_000)]), "关掉动效时一帧都不该续(截图 / CPU 档的可复现前提)" ); 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] fn a_vanished_toast_is_forgotten() { let mut tracks = tracks(MotionScale::STANDARD); - let now = Instant::now(); - tracks.observe(&[toast(1, 4_000)], now); - assert!(tracks.observe(&[toast(2, 4_000)], now), "新的那条在进场"); + tracks.observe(&[toast(1, 4_000)]); + assert!(tracks.observe(&[toast(2, 4_000)]), "新的那条在进场"); assert_eq!(tracks.progress(1), 1.0, "已消失的按在位处理(不留半截)"); assert!( !tracks.entries.iter().any(|entry| entry.id == 1), diff --git a/crates/app-iced/src/app/update/clock.rs b/crates/app-iced/src/app/update/clock.rs index 90165f1..6546b32 100644 --- a/crates/app-iced/src/app/update/clock.rs +++ b/crates/app-iced/src/app/update/clock.rs @@ -37,6 +37,18 @@ impl App { self.state .toasts .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」永远不动, // 用户会以为卡死了——这正是任务里「不得只转圈」的那一条。 diff --git a/crates/core/src/launch/crash_report.rs b/crates/core/src/launch/crash_report.rs index ec972eb..8f2a19a 100644 --- a/crates/core/src/launch/crash_report.rs +++ b/crates/core/src/launch/crash_report.rs @@ -241,8 +241,15 @@ impl CrashReport { /// 「别人的报告」而**静默不弹**。归因必须由调用方给(只有它知道是哪一场),本函数只负责 /// 把它原样记进报告——两边同源,守卫才可能判对。 /// - /// 这种失败最常见的形态就是「找不到 crash-report」——游戏根本没被执行到,所以 - /// [`Evidence`] 落到 [`Evidence::Nothing`],界面必须说「游戏还没来得及写日志」。 + /// 两个用法的**证据形态不同,判据同一个**([`CrashReport::evidence`] 只看有没有 + /// crash-report / 有没有捕获输出,**不**看 `stage`): + /// + /// | 用法 | 常见形态 | 界面该说什么 | + /// |---|---|---| + /// | `session: None`(装配 / spawn 失败) | 「找不到 crash-report」,游戏根本没被执行到 ⇒ [`Evidence::Nothing`] | 「游戏还没来得及写日志」 | + /// | `session: Some`(收尾失败) | 进程起来过、事件流有落点,只是拿不到 `SessionHandle` | 按真实证据说(多半有捕获输出) | + /// + /// 别把这张表读成「本函数只描述没起来的那种」——M298 r0 的注释正是这么写的,复审 N3 抓到。 #[must_use] pub fn before_start( ctx: &ReportContext, diff --git a/crates/presenter/src/host/launch.rs b/crates/presenter/src/host/launch.rs index 6650e88..64f50c5 100644 --- a/crates/presenter/src/host/launch.rs +++ b/crates/presenter/src/host/launch.rs @@ -82,10 +82,13 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) { Err(failure) => { let line = format!("instance={} 装配失败:{}", target.id, failure.detail); launcher_log::record("launch", &line); - publish_before_start(&report, None, failure.stage.slug(), &failure.detail); + // 归因是 `None`(进程没起来),但**照样用返回值**填 DTO:单一来源是结构, + // 不是「两边碰巧都写空串」(复审 N1)。 + let session = + publish_before_start(&report, None, failure.stage.slug(), &failure.detail); let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished { id, - session: String::new(), + session, outcome: LogOutcome::Crashed, exit_code: None, uptime_ms: 0, @@ -123,10 +126,10 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) { "launch", &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 { id, - session: String::new(), + session, outcome: LogOutcome::Crashed, exit_code: None, uptime_ms: 0,