复审 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)
237 lines
11 KiB
Rust
237 lines
11 KiB
Rust
//! 启动全链:实例 → `LaunchRequest` → 子进程 → JSONL 事件流(GUI 版)。
|
||
//!
|
||
//! 与 CLI `ull launch` 同源同序(`core` 的 `game::resolve` / `arguments.expand` /
|
||
//! `launch::adapter::build_request` / `Launcher::spawn`),差异只有两处:
|
||
//! - **不阻塞 UI**:装配与等待都在后台线程里,进度/日志经 [`super::job::HostTask`] 回流;
|
||
//! - **身份来自 GUI 选中的账户**(`AccountStore` → `AuthPlaceholders`),凭据只在
|
||
//! 本模块内被读一次,绝不进 DTO。
|
||
//!
|
||
//! 停止:`kill_tree(pid)`(core 的三段式进程树回收),`finish()` 会照常收尾写索引。
|
||
//!
|
||
//! M298 把装配那一段(`build_request` 与它的两个私有助手)搬到 [`build`],`pub use` 保路径:
|
||
//! 本文件只留「起一次启动 / 停一次启动 / 失败出口怎么登记现场」这条生命周期。
|
||
|
||
use unlucky_core::game::isolation::IsolationMode;
|
||
use unlucky_core::launch::crash_report::{self, CrashReport, ReportContext, SessionAttribution};
|
||
use unlucky_core::launch::launcher_log;
|
||
use unlucky_core::launch::tree_kill;
|
||
use unlucky_core::launch::{Launcher, LauncherConfig};
|
||
|
||
use crate::dto::{
|
||
InstanceId, InstanceRuntime, LaunchFailureStage, LaunchFailureView, LaunchFinished,
|
||
LaunchStage, LogOutcome, Progress,
|
||
};
|
||
|
||
use super::env::HostEnv;
|
||
|
||
mod build;
|
||
mod java;
|
||
|
||
use super::job::{HostTask, JobSender};
|
||
pub use build::build_request;
|
||
pub use java::pick_java;
|
||
|
||
/// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。
|
||
pub const LAUNCHER_NAME: &str = "Unlucky Launcher";
|
||
|
||
/// 一次启动需要的实例信息。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct LaunchTarget {
|
||
/// 实例 id
|
||
pub id: InstanceId,
|
||
/// 实例名
|
||
pub name: String,
|
||
/// 绑定的版本 id
|
||
pub version_id: String,
|
||
/// 账户 id(`None` = 离线占位)
|
||
pub account: Option<String>,
|
||
}
|
||
|
||
/// 起一次启动(后台线程;进度/日志/收尾全部走 `HostTask`)。
|
||
pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
||
let id = target.id.clone();
|
||
let tx_progress = tx.clone();
|
||
super::job::spawn("launch", move || {
|
||
// M187:启动器自己的运行日志从这里开始有落点(早于这一拍的行由 core 缓冲接住)。
|
||
// M188 起 presenter 在构造 `HostEnv` 时已经登记过目录(缓冲那时就刷出去了),这里是
|
||
// 幂等的一次兜底:数据根被 `redirect.json` 改过时以最后一次为准。
|
||
let logs_dir = env.layout.logs();
|
||
launcher_log::set_dir(&logs_dir);
|
||
let started_at = std::time::SystemTime::now();
|
||
// 崩溃报告里的「游戏目录」必须是**这个实例自己那个根**(M274):官方根里的实例崩了,
|
||
// 报告却写着 ULL 便携根的路径,等于把排障指到别人的目录上。
|
||
let game = super::game::game_for(&env, &target.id);
|
||
let mut report = ReportContext::new(
|
||
&target.id,
|
||
&target.name,
|
||
&target.version_id,
|
||
game.root(),
|
||
&logs_dir,
|
||
started_at,
|
||
unlucky_core::platform::os_info::current().summary_line(),
|
||
);
|
||
let _ = tx_progress.send(HostTask::Runtime {
|
||
id: id.clone(),
|
||
runtime: InstanceRuntime::Busy {
|
||
stage: LaunchStage::Launch,
|
||
progress: Progress { done: 0, total: 1 },
|
||
},
|
||
});
|
||
let (request, warnings) = match build_request(&env, &target) {
|
||
Ok(ok) => ok,
|
||
Err(failure) => {
|
||
let line = format!("instance={} 装配失败:{}", target.id, failure.detail);
|
||
launcher_log::record("launch", &line);
|
||
// 归因是 `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,
|
||
outcome: LogOutcome::Crashed,
|
||
exit_code: None,
|
||
uptime_ms: 0,
|
||
failure: Some(failure),
|
||
})));
|
||
return;
|
||
}
|
||
};
|
||
report.run_dir = request.dirs.game_dir.clone();
|
||
report.version_json = Some(game.version_json(&target.version_id));
|
||
report.java = launcher_log::java_label(&request.java.binary, request.java.major);
|
||
for warning in &warnings {
|
||
let _ = tx.send(HostTask::Notice(Box::new(crate::dto::NoticeView::new(
|
||
&format!("launch-warn:{}", target.id),
|
||
crate::dto::NoticeLevel::Info,
|
||
"启动提示",
|
||
warning,
|
||
))));
|
||
}
|
||
// 命令行(脱敏版)进启动器日志:事故之后「到底用什么参数起的」是第一个要看的。
|
||
// M211/D5:`ull launch` 写同一个文件的同一行(格式在 core 的 `launcher_log` 里)。
|
||
launcher_log::record_start(
|
||
&target.id,
|
||
&report.run_dir,
|
||
Some((request.java.binary.as_path(), request.java.major)),
|
||
request.memory.max_mb,
|
||
);
|
||
let config = LauncherConfig::new(env.sessions_root())
|
||
.with_launcher(LAUNCHER_NAME, env!("CARGO_PKG_VERSION"));
|
||
let session = match Launcher::new(config).spawn(&request) {
|
||
Ok(session) => session,
|
||
Err(error) => {
|
||
let failure = LaunchFailureView::new(LaunchFailureStage::Spawn, error.to_string());
|
||
launcher_log::record(
|
||
"launch",
|
||
&format!("instance={} spawn 失败:{error}", target.id),
|
||
);
|
||
let session = publish_before_start(&report, None, "spawn", &failure.detail);
|
||
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||
id,
|
||
session,
|
||
outcome: LogOutcome::Crashed,
|
||
exit_code: None,
|
||
uptime_ms: 0,
|
||
failure: Some(failure),
|
||
})));
|
||
return;
|
||
}
|
||
};
|
||
let session_id = session.session_id().to_string();
|
||
let pid = session.pid();
|
||
// **归属的唯一一份**(M298):收尾失败那条出口要把「这次失败属于哪一场」写进现场,
|
||
// 收尾事件也要带同一个值——两处都从这里取,界面侧的并发守卫才可能判对。
|
||
let session_log = session.log_path().to_path_buf();
|
||
let attribution = SessionAttribution {
|
||
id: &session_id,
|
||
log: Some(session_log.as_path()),
|
||
};
|
||
let _ = tx.send(HostTask::LaunchStarted {
|
||
id: id.clone(),
|
||
session_id: session_id.clone(),
|
||
pid,
|
||
});
|
||
let _ = tx.send(HostTask::Runtime {
|
||
id: id.clone(),
|
||
runtime: InstanceRuntime::Running { pid },
|
||
});
|
||
match session.finish() {
|
||
Ok(handle) => {
|
||
let outcome = super::identity::outcome_of(&handle);
|
||
launcher_log::record_end(&target.id, &handle);
|
||
report.secrets.push(session_id.clone());
|
||
let lines: Vec<String> = super::logs::read_lines(&handle.log_path)
|
||
.into_iter()
|
||
.map(|line| line.text)
|
||
.collect();
|
||
crash_report::publish(CrashReport::collect(&handle, &report, &lines));
|
||
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||
id,
|
||
// 与 `CrashReport::collect` 取自同一处(`handle.session_id`)。
|
||
session: handle.session_id.clone(),
|
||
outcome,
|
||
exit_code: Some(handle.exit.code),
|
||
uptime_ms: handle.uptime_ms,
|
||
// 跑起来再崩不算「装配失败」:结局由 `outcome` 表达,这里留 `None`。
|
||
failure: None,
|
||
})));
|
||
}
|
||
Err(error) => {
|
||
let failure = LaunchFailureView::new(LaunchFailureStage::Spawn, error.to_string());
|
||
let line = format!(
|
||
"instance={} session={session_id} 收尾失败:{error}",
|
||
target.id
|
||
);
|
||
launcher_log::record("launch", &line);
|
||
// **收尾失败时进程真的起来过** ⇒ 这份现场属于 `attribution` 那一场。
|
||
// 返回值就是报告里写的那条会话 id(同一处取值),所以「报告空串 vs 事件非空」
|
||
// 这种守卫必然误判的组合从构造上就不存在了(M298)。
|
||
let session =
|
||
publish_before_start(&report, Some(&attribution), "spawn", &failure.detail);
|
||
let _ = tx.send(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||
id,
|
||
session,
|
||
outcome: LogOutcome::SilentFail,
|
||
exit_code: None,
|
||
uptime_ms: 0,
|
||
failure: Some(failure),
|
||
})));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 登记一次**没有 handle** 的失败现场,并返回该写进 `LaunchFinished.session` 的值。
|
||
///
|
||
/// **会话归属的单一来源**(M298):报告里的 `session` 与收尾事件里的 `session` 都由
|
||
/// `attribution` 这一处产出——调用方直接拿返回值填 DTO,于是两处不会再各写一遍、也不会出现
|
||
/// 「报告写空串、事件写真实会话」这种守卫必然误判的组合。
|
||
///
|
||
/// `attribution` 为 `None` = 这一场**确实**没有会话(装配失败 / spawn 失败,进程没起来)。
|
||
fn publish_before_start(
|
||
report: &ReportContext,
|
||
attribution: Option<&SessionAttribution<'_>>,
|
||
stage: &str,
|
||
detail: &str,
|
||
) -> String {
|
||
let registered = CrashReport::before_start(report, stage, detail, stage, attribution.copied());
|
||
let session = registered.session.clone();
|
||
crash_report::publish(registered);
|
||
session
|
||
}
|
||
|
||
/// 停止一次启动(按 pid 回收进程树;`finish()` 会在后台线程里照常收尾)。
|
||
pub fn stop(pid: u32) -> Result<(), String> {
|
||
tree_kill::kill_tree(pid, std::time::Duration::from_secs(3))
|
||
.map_err(|error| format!("停止进程 {pid} 失败:{error}"))
|
||
}
|
||
|
||
/// 隔离档位的展示串(`isolated` / `shared`)。
|
||
pub fn isolation_label(mode: IsolationMode) -> &'static str {
|
||
mode.as_str()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|