Files
Unlucky-Launcher/crates/core/src/launch/launch_session.rs
T
AskaEth bc41dfcf7d
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

330 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.
//! 运行中的启动会话与收尾流水线。
//!
//! 一次启动的生命周期(`DevDocs/PlanDoc/06-launch-and-diagnostics.md` §1 的第 7~10 阶段):
//! `spawn` → 用户可能 [`LaunchSession::stop`] → [`LaunchSession::finish`]
//! (等进程 → 等日志泵(有预算)→ 退出分析 → 扫崩溃现场 → 诊断挂钩 → 写事件 + 索引)。
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
use serde_json::Value;
use crate::launch::args_table::AssembledCommand;
use crate::launch::crash_scan::{self, CrashArtifact};
use crate::launch::diagnosis_hook::{DiagnosisHook, DiagnosisRequest};
use crate::launch::error::Result;
use crate::launch::exit_analysis::{self, ExitAnalysis, ExitSignals};
use crate::launch::launcher::LauncherConfig;
use crate::launch::process::{self, RunningProcess};
use crate::launch::pump::{LineSink, LogLine};
use crate::launch::redact::Redactor;
use crate::launch::request::LaunchRequest;
use crate::launch::session::{EndReason, SessionSink};
use crate::launch::session_index::SessionIndexEntry;
/// 一次运行中的启动会话。
pub struct LaunchSession {
config: LauncherConfig,
hook: Arc<dyn DiagnosisHook>,
redactor: Redactor,
request: LaunchRequest,
session_id: String,
log_path: PathBuf,
command: AssembledCommand,
pid: u32,
process: RunningProcess,
sink: Arc<SessionSink>,
started_wall: SystemTime,
killed: bool,
}
impl std::fmt::Debug for LaunchSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LaunchSession")
.field("session_id", &self.session_id)
.field("pid", &self.pid)
.field("log_path", &self.log_path)
.finish()
}
}
impl LaunchSession {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
config: LauncherConfig,
hook: Arc<dyn DiagnosisHook>,
redactor: Redactor,
request: LaunchRequest,
session_id: String,
log_path: PathBuf,
command: AssembledCommand,
pid: u32,
process: RunningProcess,
sink: Arc<SessionSink>,
) -> Self {
Self {
config,
hook,
redactor,
request,
session_id,
log_path,
command,
pid,
process,
sink,
started_wall: SystemTime::now(),
killed: false,
}
}
/// 进程号。
pub fn pid(&self) -> u32 {
self.pid
}
/// 会话 id。
pub fn session_id(&self) -> &str {
&self.session_id
}
/// 事件流文件路径。
pub fn log_path(&self) -> &Path {
&self.log_path
}
/// 组装结果。
pub fn command(&self) -> &AssembledCommand {
&self.command
}
/// 是否已请求停止。
pub fn is_killed(&self) -> bool {
self.killed
}
/// 日志 sink(可再叠加 UI/测试消费者,见 [`crate::launch::sink`])。
pub fn sink(&self) -> Arc<dyn LineSink> {
self.sink.clone() as Arc<dyn LineSink>
}
/// 已运行毫秒数。
pub fn uptime_ms(&self) -> u64 {
self.process.uptime_ms()
}
/// 用户/看门狗请求停止:三段式回收进程树,并记 `killed` 事件。
pub fn stop(&mut self) -> Result<()> {
if self.killed {
return Ok(());
}
self.killed = true;
if let Some(result) = self.sink.with_writer(|writer| writer.killed("user", None)) {
let _ = result;
}
self.process.stop()
}
/// 收尾:等进程 → 等日志泵 → 退出分析 → 现场扫描 → 诊断 → 事件 + 索引。
pub fn finish(mut self) -> Result<SessionHandle> {
let status = self.process.wait()?;
let code = process::exit_code(&status);
// uptime 必须在等日志泵之前取(泵的等待预算不算进游戏存活时间)。
let uptime_ms = self.process.uptime_ms();
let verdict = self.process.supervise();
let pump_stalled = !verdict.is_complete();
let lines = self.process.lines();
let mut signals = ExitSignals::new(code, uptime_ms, &lines);
signals.killed = self.killed;
signals.is_posix = self.config.platform.os.is_posix();
let exit = exit_analysis::analyze(&signals);
let artifacts = self.collect_artifacts();
// 脱敏只算一次:诊断包与 `SessionHandle::redacted_argv()` 共用。
let redacted_argv = self.redactor.scrub_all(&self.command.argv());
let diagnosis_request = self.build_diagnosis_request(
&exit,
&lines,
&artifacts,
uptime_ms,
redacted_argv.clone(),
);
let should_diagnose = exit.needs_diagnosis || !artifacts.is_empty();
let diagnosis = if should_diagnose {
self.hook.analyze(&diagnosis_request)
} else {
None
};
let mut event_write_errors = Vec::new();
let mut diagnostics = None;
let mut index = None;
let end_reason = if self.killed {
EndReason::Killed
} else {
EndReason::Exit
};
let completed = self.sink.with_writer(|writer| {
if let Err(err) = writer.exit(&exit) {
event_write_errors.push(err.to_string());
}
for artifact in &artifacts {
let result = writer.crash_found(
artifact.source.as_str(),
&artifact.path,
artifact.excerpt.as_deref(),
);
if let Err(err) = result {
event_write_errors.push(err.to_string());
}
}
if let Some(record) = diagnosis {
if let Err(err) = writer.diagnosis(record.clone()) {
event_write_errors.push(err.to_string());
}
diagnostics = Some(record);
}
match writer.finish(end_reason) {
Ok(entry) => index = Some(entry),
Err(err) => event_write_errors.push(err.to_string()),
}
});
if completed.is_none() {
event_write_errors.push("会话写入器不可用(锁中毒)".to_string());
}
Ok(SessionHandle {
session_id: self.session_id,
log_path: self.log_path,
command: self.command,
redacted_argv,
exit,
diagnostics,
index,
artifacts,
uptime_ms,
pump_stalled,
event_write_errors,
})
}
fn collect_artifacts(&self) -> Vec<CrashArtifact> {
if !self.config.scan_crash_reports {
return Vec::new();
}
let run_dir = self.run_dir();
crash_scan::scan(&run_dir, self.started_wall, self.config.max_crash_artifacts)
}
fn build_diagnosis_request(
&self,
exit: &ExitAnalysis,
lines: &[LogLine],
artifacts: &[CrashArtifact],
uptime_ms: u64,
argv: Vec<String>,
) -> DiagnosisRequest {
DiagnosisRequest {
session_id: self.session_id.clone(),
instance_id: self.request.instance.id.clone(),
instance_name: self.request.instance.name.clone(),
game_version: self.request.instance.version.raw().to_string(),
exit: exit.clone(),
uptime_ms,
log_lines: lines
.iter()
.map(|line| LogLine::new(line.stream, self.redactor.scrub(&line.text).into_owned()))
.collect(),
artifacts: artifacts.to_vec(),
argv,
memory_mb: self.command.memory.max_mb,
java_major: self.request.java.major,
java_binary: self.request.java.binary.clone(),
game_dir: self.run_dir(),
}
}
fn run_dir(&self) -> PathBuf {
if self.request.dirs.game_dir.as_os_str().is_empty() {
std::env::current_dir().unwrap_or_default()
} else {
self.request.dirs.game_dir.clone()
}
}
}
/// 一次启动的结果。
#[derive(Debug, Clone)]
pub struct SessionHandle {
/// 会话 id。
pub session_id: String,
/// 事件流文件。
pub log_path: PathBuf,
/// 组装结果(`argv()` 含凭据明文;对外展示/上报请用 [`Self::redacted_argv`])。
pub command: AssembledCommand,
/// 脱敏后的完整 argv(accessToken → `<redacted:token>`,主目录 → `<home>`)。
redacted_argv: Vec<String>,
/// 退出判定。
pub exit: ExitAnalysis,
/// 诊断结论(M15 产出,已写入事件流)。
pub diagnostics: Option<Value>,
/// 索引条目(写入 `index.jsonl` 的那一行)。
pub index: Option<SessionIndexEntry>,
/// 崩溃现场文件。
pub artifacts: Vec<CrashArtifact>,
/// 游戏存活毫秒数。
pub uptime_ms: u64,
/// 日志泵是否超时(watchdog 触发 = 有子进程没关 stdout)。
pub pump_stalled: bool,
/// 会话写入错误(磁盘问题;不影响进程结果)。
pub event_write_errors: Vec<String>,
}
impl SessionHandle {
/// 脱敏后的完整 argv(UI/日志/上报都用它,别直接外发 `command.argv()`)。
pub fn redacted_argv(&self) -> &[String] {
&self.redacted_argv
}
/// 脱敏后的一行人读命令行。
pub fn redacted_display(&self) -> String {
self.redacted_argv
.iter()
.map(|arg| crate::launch::command_builder::quote_for_display(arg))
.collect::<Vec<_>>()
.join(" ")
}
/// 是否“正常且无异常”:退出判定 NORMAL 且没有静默失败标记。
pub fn succeeded(&self) -> bool {
!self.exit.exit_type.is_abnormal() && self.exit.silent_failure.is_none()
}
/// 是否需要进诊断(等价于 `exit.needs_diagnosis`,保留给上层判空)。
pub fn needs_diagnosis(&self) -> bool {
self.exit.needs_diagnosis
}
/// 一句话摘要。
pub fn summary(&self) -> String {
let mut text = format!("会话 {}:{}", self.session_id, self.exit.summary());
if self.pump_stalled {
text.push_str(";日志泵超时");
}
if let Some(top) = &self.diagnostics {
if let Some(id) = top
.get("candidates")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|item| item.get("id"))
.and_then(Value::as_str)
{
text.push_str(&format!(";诊断首因 {id}"));
}
}
text
}
}