Files
Unlucky-Launcher/crates/presenter/src/host/launch.rs
T
AskaEth 3b4f8bb997 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)
2026-11-13 13:50:41 +08:00

237 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 启动全链:实例 → `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;