fix(core): 老版本 Java 按版本距离选 + 启动看门狗有界等待 + 结束归属(M214 D4/D6/D10)
D4:`pick` 的排序键从「来源序」改成「版本距离 → 来源序」,全不满足时选最接近要求的那个 (托管 25 与 JAVA_HOME/扫盘 17 之间选 17,而不是按 rank 选托管)。新增 `select::decide`: 不符且**没有人显式指定 Java** 时 `ull launch` 直接报 `java_mismatch` 不 spawn(`--java` / 实例 `javaPath` 是豁免,只告警)——1.12.2 配 Java 17/25 必然起不来,警告没有信息量。 `JavaMismatch` + `check` 拆到 `mismatch.rs` 并加 `advice()`(mapping.rs 408→282 行)。 D6:`LaunchSession::finish` 的等进程改成**有界**。判据在 `startup_watch.rs`(纯逻辑): ①窗口从未出现且**连续 N 秒没有任何日志**(还在打日志就说明还在加载,慢加载不被判死); ②日志出现关闭标记后 N 秒仍未退出(实机「关窗后 java.exe 仍存活」的形状)。 到点只**停止等待、绝不杀进程**:写 `session_end(still_running)` + 索引 + `launcher.log`, 返回 `LaunchError::Watchdog`,CLI 翻成 `launch_timeout`。两个时长可配 (默认 300s/60s;`ULL_LAUNCH_STARTUP_TIMEOUT_SECS` / `ULL_LAUNCH_SHUTDOWN_GRACE_SECS`, 生效值每次启动写进 launcher.log 与人读输出)。 D10:把「被终止」与「应用错误」分开。①我们自己杀的:`tree_kill` **送达即登记** `kill_registry`(pid 登记簿,取走语义、上限 64),覆盖 GUI 从别的线程 `kill_tree` 的路径, `finish` 据此判 `killed`;②外部强杀:Windows 上「退出码 1 + 零错误输出 + 无崩溃/JVM 关键字」 判 `killed` 并标 `terminated_externally`(任务管理器与 `taskkill /F` 都是 `TerminateProcess(h, 1)`,应用真崩给的是 NTSTATUS 异常码)。 顺带:`SessionHandle` 拆 `session_handle.rs`(launch_session.rs 422→343 行)、 `NativesReport::to_json` 落 core(cli/launch.rs 腾出空间,仍 399 行)。 用例:select 距离/取舍、startup_watch 状态机、watchdog_cases(4 条真进程)、 exit_cases(外部终止与「我们杀的」区分)、pipeline 跨线程 kill。
This commit is contained in:
1 parent
5959a27add
commit
ff78fe2cae
23 files changed
+1708
-250
No files matched your search
@@ -12,6 +12,7 @@ use unlucky_core::game::install::instance::{
|
||||
read_instance_config, write_instance_config, InstanceConfig, Isolation,
|
||||
};
|
||||
use unlucky_core::game::isolation::{derive_game_dir, observe_mode, IsolationMode};
|
||||
use unlucky_core::game::java::select;
|
||||
use unlucky_core::game::ProfileList;
|
||||
use unlucky_core::launch::time::{format_rfc3339_millis, now};
|
||||
use unlucky_core::launch::{LaunchError, Launcher, LauncherConfig};
|
||||
@@ -98,8 +99,19 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
|
||||
..input
|
||||
};
|
||||
|
||||
// D13:实例覆盖 > 托管匹配 > 系统。托管命中时把路径钉死交给 `snapshot::prepare`。
|
||||
// D13 选 Java(托管命中就把路径钉死交给 `snapshot::prepare`);`input.java` 下面会被换成
|
||||
// 「生效路径」,先记下「用户有没有自己指定 Java」。D4:不符且没人显式指定就拦,不 spawn。
|
||||
unlucky_core::launch::launcher_log::set_dir(ctx.logs_dir());
|
||||
let explicit_java = input.java.is_some();
|
||||
let java = super::java::resolve_for_launch(ctx, &input.version_id, input.java.as_deref());
|
||||
if !args.dry_run {
|
||||
if let Some(mismatch) = select::decide(java.mismatch, explicit_java).block() {
|
||||
unlucky_core::launch::launcher_log::record("launch", &mismatch.message());
|
||||
return Err(
|
||||
CliError::user("java_mismatch", mismatch.message()).with_hint(mismatch.advice())
|
||||
);
|
||||
}
|
||||
}
|
||||
let input = PlanInput {
|
||||
java: java.override_path.clone(),
|
||||
..input
|
||||
@@ -107,7 +119,6 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
|
||||
|
||||
// M188/M211:命令行这一路也把启动器日志写进 `<data>/logs/launcher.log`
|
||||
// (落点与格式的真相都在 core 的 `launcher_log`,GUI 写的是同一份)。
|
||||
unlucky_core::launch::launcher_log::set_dir(ctx.logs_dir());
|
||||
unlucky_core::launch::launcher_log::record_start(
|
||||
&input.instance_id,
|
||||
&isolation.game_dir,
|
||||
@@ -118,9 +129,14 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
|
||||
);
|
||||
let plan = snapshot::prepare(ctx, &input)?;
|
||||
let config = LauncherConfig::new(ctx.sessions_root())
|
||||
.with_launcher(super::meta::LAUNCHER_NAME, env!("CARGO_PKG_VERSION"));
|
||||
.with_launcher(super::meta::LAUNCHER_NAME, env!("CARGO_PKG_VERSION"))
|
||||
.with_watchdog_env();
|
||||
// D6:两个看门狗时长的**生效值**落 launcher.log —— 卡死之后第一个要看的就是「等了多久才放弃」。
|
||||
let watchdog = config.watchdog_summary();
|
||||
unlucky_core::launch::launcher_log::record("launch", &watchdog);
|
||||
let launcher = Launcher::new(config);
|
||||
let mut human = Vec::new();
|
||||
human.push(watchdog);
|
||||
human.push(format!(
|
||||
"隔离档位 {}(来源 {})· 运行目录 {}",
|
||||
isolation.isolation.as_str(),
|
||||
@@ -158,18 +174,7 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
|
||||
lines.push(format!("告警: {warning}"));
|
||||
}
|
||||
human.extend(lines);
|
||||
let natives = dry.natives.as_ref().map(|natives| {
|
||||
json!({
|
||||
"dir": natives.dir.display().to_string(),
|
||||
"files": natives.files,
|
||||
"skipped": natives.skipped,
|
||||
"ignored": natives.ignored,
|
||||
"jars": natives.jars,
|
||||
"custom": natives.custom,
|
||||
"up_to_date": natives.up_to_date,
|
||||
"era": natives.era.as_str(),
|
||||
})
|
||||
});
|
||||
let natives = dry.natives.as_ref().map(|natives| natives.to_json());
|
||||
let data = json!({
|
||||
"dry_run": true,
|
||||
"instance_found": instance_found,
|
||||
@@ -336,6 +341,11 @@ fn global_mode(ctx: &Ctx) -> IsolationMode {
|
||||
}
|
||||
|
||||
fn map_launch_error(error: &LaunchError, ctx: &Ctx) -> CliError {
|
||||
// D6:看门狗超时是一个**可区分的**结局(脚本要能单独处理),且自带现场与可调项,
|
||||
// 不该被塞上「版本本体目录」这种无关提示。
|
||||
if let LaunchError::Watchdog { .. } = error {
|
||||
return CliError::user("launch_timeout", error.to_string());
|
||||
}
|
||||
let hint = format!("版本本体目录:{}", ctx.game().versions_dir().display());
|
||||
CliError::user("launch", error.to_string()).with_hint(hint)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! MC 版本 ↔ Java 版本映射(平移 HMCL 约束体系)+ 不符检查。
|
||||
//! MC 版本 ↔ Java 版本映射(平移 HMCL 约束体系);不符判定与文案见
|
||||
//! [`crate::game::java::mismatch`](其中的 `check` / `JavaMismatch` 仍从本模块转出,
|
||||
//! 调用方的 `mapping::check` 写法不变)。
|
||||
//!
|
||||
//! 依据 `DevDocs/analyse/02-launch-pipeline.md` §5.4(HMCL `GameJavaVersion.getMinimumJavaVersion`)
|
||||
//! 与 §3.1 的强制约束清单(`JavaVersionConstraint` 的 `isMandatory` 项)。两条权威叠加:
|
||||
@@ -25,6 +27,8 @@ use super::super::inherit::resolve;
|
||||
use super::super::layout::GameLayout;
|
||||
use super::super::manifest::{JavaVersion, VersionJson};
|
||||
|
||||
pub use super::mismatch::{check, JavaMismatch};
|
||||
|
||||
/// 要求来源(`--json` 的 `requirement_source`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RequirementSource {
|
||||
@@ -90,66 +94,6 @@ impl JavaRequirement {
|
||||
}
|
||||
}
|
||||
|
||||
/// 不符的两种形态(D13:只提示,不阻断)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum JavaMismatch {
|
||||
/// Java 太低。
|
||||
TooOld {
|
||||
/// 需要的最低主版本。
|
||||
required: u32,
|
||||
/// 实际主版本。
|
||||
actual: u32,
|
||||
},
|
||||
/// Java 太高(老版本只能用 8)。
|
||||
TooNew {
|
||||
/// 允许的最高主版本。
|
||||
maximum: u32,
|
||||
/// 实际主版本。
|
||||
actual: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl JavaMismatch {
|
||||
/// 稳定字符串 id(`--json` 的 `mismatch.code`)。
|
||||
#[must_use]
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::TooOld { .. } => "java_too_old",
|
||||
Self::TooNew { .. } => "java_too_new",
|
||||
}
|
||||
}
|
||||
|
||||
/// 人读说明(GUI 弹窗 P1 后直接复用这句)。
|
||||
#[must_use]
|
||||
pub fn message(self) -> String {
|
||||
match self {
|
||||
Self::TooOld { required, actual } => format!(
|
||||
"该版本需要 Java {required} 及以上,当前用的是 Java {actual}(大概率起不来)"
|
||||
),
|
||||
Self::TooNew { maximum, actual } => format!(
|
||||
"该版本只能用 Java {maximum} 及以下,当前用的是 Java {actual}(老加载器吃不了新 class 文件)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 判需求与实际是否相符(`None` = 相符)。
|
||||
#[must_use]
|
||||
pub fn check(actual: u32, required: &JavaRequirement) -> Option<JavaMismatch> {
|
||||
if actual < required.minimum {
|
||||
return Some(JavaMismatch::TooOld {
|
||||
required: required.minimum,
|
||||
actual,
|
||||
});
|
||||
}
|
||||
if let Some(maximum) = required.maximum {
|
||||
if actual > maximum {
|
||||
return Some(JavaMismatch::TooNew { maximum, actual });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 静态表:MC 版本 → Java 要求。
|
||||
///
|
||||
/// 无法解析的版本(周快照 `20w14a`、魔改 id)只给下限 `8`、不设上限——
|
||||
@@ -281,7 +225,7 @@ fn at_least(parts: &[u32], floor: &[u32]) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{check, recommended_major, requirement_for, JavaMismatch, JavaRequirement};
|
||||
use super::{recommended_major, requirement_for, JavaRequirement};
|
||||
|
||||
#[test]
|
||||
fn table_covers_the_hmcl_boundaries() {
|
||||
@@ -330,47 +274,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_reports_both_directions() {
|
||||
let modern = JavaRequirement::table(21, None);
|
||||
assert_eq!(check(21, &modern), None);
|
||||
assert_eq!(check(25, &modern), None);
|
||||
assert_eq!(
|
||||
check(17, &modern),
|
||||
Some(JavaMismatch::TooOld {
|
||||
required: 21,
|
||||
actual: 17
|
||||
})
|
||||
);
|
||||
let legacy = JavaRequirement::table(8, Some(8));
|
||||
assert_eq!(check(8, &legacy), None);
|
||||
assert_eq!(
|
||||
check(21, &legacy),
|
||||
Some(JavaMismatch::TooNew {
|
||||
maximum: 8,
|
||||
actual: 21
|
||||
})
|
||||
);
|
||||
assert_eq!(legacy.describe(), "Java 8");
|
||||
assert_eq!(modern.describe(), "Java 21+");
|
||||
fn describe_spells_out_the_interval() {
|
||||
assert_eq!(requirement_for("1.12.2").describe(), "Java 8");
|
||||
assert_eq!(requirement_for("1.20.5").describe(), "Java 21+");
|
||||
assert_eq!(JavaRequirement::table(8, Some(16)).describe(), "Java 8–16");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_messages_name_both_sides() {
|
||||
let message = JavaMismatch::TooOld {
|
||||
required: 21,
|
||||
actual: 8,
|
||||
}
|
||||
.message();
|
||||
assert!(message.contains("Java 21"));
|
||||
assert!(message.contains("Java 8"));
|
||||
assert_eq!(
|
||||
JavaMismatch::TooNew {
|
||||
maximum: 8,
|
||||
actual: 21
|
||||
}
|
||||
.code(),
|
||||
"java_too_new"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Java 与目标版本**不符**的判定与文案(从 `mapping` 拆出:那边只管版本表与清单推导)。
|
||||
//!
|
||||
//! 两种形态(D13 引入,D4 起参与启动决策):
|
||||
//!
|
||||
//! - [`JavaMismatch::TooOld`]:新版本配了太老的 Java(1.20.5+ 需要 21,手里只有 17);
|
||||
//! - [`JavaMismatch::TooNew`]:老版本配了新 Java(1.12.2 只能用 8,托管里只有 21/25)。
|
||||
//!
|
||||
//! 这里只产出数据与人读文案;**拦不拦**由调用方决定(CLI 见
|
||||
//! [`crate::game::java::select::decide`]:不符且用户没显式指定 Java 就拦住)。
|
||||
|
||||
use super::mapping::JavaRequirement;
|
||||
|
||||
/// 不符的两种形态。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum JavaMismatch {
|
||||
/// Java 太低。
|
||||
TooOld {
|
||||
/// 需要的最低主版本。
|
||||
required: u32,
|
||||
/// 实际主版本。
|
||||
actual: u32,
|
||||
},
|
||||
/// Java 太高(老版本只能用 8)。
|
||||
TooNew {
|
||||
/// 允许的最高主版本。
|
||||
maximum: u32,
|
||||
/// 实际主版本。
|
||||
actual: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl JavaMismatch {
|
||||
/// 稳定字符串 id(`--json` 的 `mismatch.code`)。
|
||||
#[must_use]
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::TooOld { .. } => "java_too_old",
|
||||
Self::TooNew { .. } => "java_too_new",
|
||||
}
|
||||
}
|
||||
|
||||
/// 人读说明(GUI 弹窗 P1 后直接复用这句)。
|
||||
#[must_use]
|
||||
pub fn message(self) -> String {
|
||||
match self {
|
||||
Self::TooOld { required, actual } => format!(
|
||||
"该版本需要 Java {required} 及以上,当前用的是 Java {actual}(大概率起不来)"
|
||||
),
|
||||
Self::TooNew { maximum, actual } => format!(
|
||||
"该版本只能用 Java {maximum} 及以下,当前用的是 Java {actual}(老加载器吃不了新 class 文件)"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一句**可操作**的建议(D4:拦下来之后必须告诉用户怎么继续)。
|
||||
///
|
||||
/// 两个出口:装一个对版本的 JDK,或用 `--java` 明确指定要试的那个(显式指定不再被拦)。
|
||||
#[must_use]
|
||||
pub fn advice(self) -> String {
|
||||
let wanted = match self {
|
||||
Self::TooOld { required, .. } => required,
|
||||
Self::TooNew { maximum, .. } => maximum,
|
||||
};
|
||||
format!(
|
||||
"装一个 Java {wanted}(`java install {wanted}`),或用 `--java <路径>` 指定要试的那个"
|
||||
)
|
||||
}
|
||||
|
||||
/// 实际主版本号。
|
||||
#[must_use]
|
||||
pub const fn actual(self) -> u32 {
|
||||
match self {
|
||||
Self::TooOld { actual, .. } | Self::TooNew { actual, .. } => actual,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 判需求与实际是否相符(`None` = 相符)。
|
||||
#[must_use]
|
||||
pub fn check(actual: u32, required: &JavaRequirement) -> Option<JavaMismatch> {
|
||||
if actual < required.minimum {
|
||||
return Some(JavaMismatch::TooOld {
|
||||
required: required.minimum,
|
||||
actual,
|
||||
});
|
||||
}
|
||||
if let Some(maximum) = required.maximum {
|
||||
if actual > maximum {
|
||||
return Some(JavaMismatch::TooNew { maximum, actual });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{check, JavaMismatch};
|
||||
use crate::game::java::mapping::JavaRequirement;
|
||||
|
||||
#[test]
|
||||
fn check_reports_both_directions() {
|
||||
let modern = JavaRequirement::table(21, None);
|
||||
assert_eq!(check(21, &modern), None);
|
||||
assert_eq!(check(25, &modern), None);
|
||||
assert_eq!(
|
||||
check(17, &modern),
|
||||
Some(JavaMismatch::TooOld {
|
||||
required: 21,
|
||||
actual: 17
|
||||
})
|
||||
);
|
||||
let legacy = JavaRequirement::table(8, Some(8));
|
||||
assert_eq!(check(8, &legacy), None);
|
||||
assert_eq!(
|
||||
check(21, &legacy),
|
||||
Some(JavaMismatch::TooNew {
|
||||
maximum: 8,
|
||||
actual: 21
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_name_both_sides_and_codes_are_stable() {
|
||||
let message = JavaMismatch::TooOld {
|
||||
required: 21,
|
||||
actual: 8,
|
||||
}
|
||||
.message();
|
||||
assert!(message.contains("Java 21"));
|
||||
assert!(message.contains("Java 8"));
|
||||
assert_eq!(
|
||||
JavaMismatch::TooNew {
|
||||
maximum: 8,
|
||||
actual: 21
|
||||
}
|
||||
.code(),
|
||||
"java_too_new"
|
||||
);
|
||||
assert_eq!(
|
||||
JavaMismatch::TooNew {
|
||||
maximum: 8,
|
||||
actual: 21
|
||||
}
|
||||
.actual(),
|
||||
21
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advice_names_the_major_and_the_escape_hatch() {
|
||||
let advice = JavaMismatch::TooNew {
|
||||
maximum: 8,
|
||||
actual: 17,
|
||||
}
|
||||
.advice();
|
||||
assert!(advice.contains("Java 8"), "{advice}");
|
||||
assert!(advice.contains("java install 8"), "{advice}");
|
||||
assert!(advice.contains("--java"), "{advice}");
|
||||
assert!(JavaMismatch::TooOld {
|
||||
required: 21,
|
||||
actual: 17
|
||||
}
|
||||
.advice()
|
||||
.contains("java install 21"));
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,18 @@
|
||||
//! extract zip / tar.gz 解压 + 顶层目录剥离 + 幂等戳记 + zip-slip 防护
|
||||
//! probe java -version 探测、托管目录扫描(两种布局都认)
|
||||
//! discover 标准安装位置扫描(Program Files 系 / 官方启动器 runtime / 注册表 / /usr/lib/jvm)+ 去重
|
||||
//! select 启动选 Java 的**纯**优先链(满足要求优先,再按来源)
|
||||
//! mapping MC↔Java 静态表(HMCL `GameJavaVersion`)+ 版本 JSON 优先 + 不符检查
|
||||
//! select 启动选 Java 的**纯**优先链(满足要求优先,全不满足时按版本距离,再按来源)
|
||||
//! mapping MC↔Java 静态表(HMCL `GameJavaVersion`)+ 版本 JSON 优先
|
||||
//! mismatch 不符判定(太老 / 太新)+ 说明与可操作建议
|
||||
//! ```
|
||||
//!
|
||||
//! # 三条边界
|
||||
//!
|
||||
//! 1. **不碰网络细节**:包下载一律交 `core::download` 的引擎,本模块不写重试循环
|
||||
//! (`MirrorTable::expand` 负责 Temurin 的镜像展开;Oracle 的目录解析在 `oracle`);
|
||||
//! 2. **不弹窗**:不符检查只产出数据([`JavaMismatch`]),提示交给 CLI 的告警行与 GUI;
|
||||
//! 2. **不弹窗**:不符检查只产出数据([`JavaMismatch`])与取舍结论([`LaunchVerdict`]),
|
||||
//! 提示与拦截都交给调用方(D4:不符且用户没显式指定 Java 时 `ull launch` 直接报错,
|
||||
//! 显式 `--java` 则只告警);
|
||||
//! 3. **不改启动策略**:「用哪个 Java 启动」是启动期的派生结果,不落盘;落盘的是**装到哪**
|
||||
//! ([`JavaInstallRoot`],由 `java.installRoot` + 数据目录模式决定)。
|
||||
//!
|
||||
@@ -49,6 +52,7 @@ pub mod location;
|
||||
pub mod manual;
|
||||
pub mod mapping;
|
||||
pub mod microsoft;
|
||||
pub mod mismatch;
|
||||
pub mod net;
|
||||
pub mod oracle;
|
||||
pub mod probe;
|
||||
@@ -92,7 +96,7 @@ pub use self::probe::{
|
||||
resolve_on_path, system_candidates, JavaOrigin, JavaRuntime, DEFAULT_MAJOR,
|
||||
};
|
||||
pub use self::release::parse_sha256_text;
|
||||
pub use self::select::{pick, resolve, JavaResolution};
|
||||
pub use self::select::{decide, distance, pick, resolve, JavaResolution, LaunchVerdict};
|
||||
pub use self::source::{ChainLog, VendorAttempt};
|
||||
pub use self::target::AdoptiumTarget;
|
||||
pub use self::vendor::{order as vendor_order, JdkVendor, VENDOR_IDS};
|
||||
@@ -7,9 +7,11 @@
|
||||
//! 挑选规则(比「机械按来源排序」更贴近用户预期):
|
||||
//!
|
||||
//! 1. **满足要求的候选优先**——1.12.2 只能用 Java 8,托管的 21 再新也不能选;
|
||||
//! 2. 同档内按来源:托管 > `JAVA_HOME` > `PATH`(D13:托管目录是启动器的地盘,
|
||||
//! 用户 PATH 上的 java 可能是另一个 JDK 的残留);
|
||||
//! 3. 全都不满足时仍给一个(按来源优先取最好那个),并报不符——**提示而不是拒绝**。
|
||||
//! 2. 全都不满足时按**版本距离**([`distance`])取最接近的那个,同距离再按来源:
|
||||
//! 托管 > `JAVA_HOME` > `PATH`(D13:托管目录是启动器的地盘,用户 PATH 上的 java
|
||||
//! 可能是另一个 JDK 的残留)。距离优先而非来源优先是 D4:托管里只有 25、`JAVA_HOME`
|
||||
//! 是 21 时,1.12.2 该被推上更接近的 21,而不是机械地选「来源排名更高」的托管 25;
|
||||
//! 3. 挑完还要决定**要不要拦**([`decide`]):不符且用户没显式指定 Java 就不启动。
|
||||
//!
|
||||
//! 抽成纯函数是为了可测:测试注入「托管 21 + PATH 8」这类组合,不必真装 JDK。
|
||||
|
||||
@@ -43,21 +45,70 @@ impl JavaResolution {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从候选里挑一个:满足要求的优先,同档内按来源优先序。
|
||||
/// 候选主版本号与要求区间的**距离**(`0` = 满足要求)。
|
||||
///
|
||||
/// D4:主版本号是离散档位,用户装的 `17` 与 `25` 对「只能用 8」的 1.12.2 都是错的,
|
||||
/// 但 `17` 只差 9 档、`25` 差 17 档——真机上「离得近」的那个起得来的概率明显更高
|
||||
/// (老版本对新 class 文件的容忍度随版本差递减)。
|
||||
#[must_use]
|
||||
pub fn distance(major: u32, required: &JavaRequirement) -> u32 {
|
||||
if major < required.minimum {
|
||||
return required.minimum - major;
|
||||
}
|
||||
match required.maximum {
|
||||
Some(maximum) if major > maximum => major - maximum,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从候选里挑一个:先按「离要求多远」,同距离再按来源优先序。
|
||||
///
|
||||
/// 探不到主版本号的候选排最后(不知道就不能算“近”)。
|
||||
#[must_use]
|
||||
pub fn pick<'a>(
|
||||
candidates: &'a [JavaRuntime],
|
||||
required: &JavaRequirement,
|
||||
) -> Option<&'a JavaRuntime> {
|
||||
candidates.iter().min_by_key(|runtime| {
|
||||
let ok = runtime
|
||||
let distance = runtime
|
||||
.major
|
||||
.is_some_and(|major| required.satisfied_by(major));
|
||||
// 先排「不满足」(`ok=false` 的 key 大),再排来源优先序。
|
||||
(!ok, runtime.origin.rank())
|
||||
.map_or(u32::MAX, |major| distance(major, required));
|
||||
// 先排「距离远」,再排来源优先序(托管 > JAVA_HOME > PATH)。
|
||||
(distance, runtime.origin.rank())
|
||||
})
|
||||
}
|
||||
|
||||
/// 启动前的取舍(D4)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LaunchVerdict {
|
||||
/// 照常启动(满足要求,或用户显式指定了 Java——那是他的明确选择)。
|
||||
Proceed,
|
||||
/// 拦下:不满足要求且没人明确指定,起不来是必然的,那句警告没有信息量。
|
||||
Block(JavaMismatch),
|
||||
}
|
||||
|
||||
impl LaunchVerdict {
|
||||
/// 需要拦下时返回不符说明。
|
||||
#[must_use]
|
||||
pub fn block(self) -> Option<JavaMismatch> {
|
||||
match self {
|
||||
Self::Proceed => None,
|
||||
Self::Block(mismatch) => Some(mismatch),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 不满足要求时是「只告警」还是「拦住」(D4 的取舍,理由见 [`LaunchVerdict`])。
|
||||
///
|
||||
/// 判据只有两条:不符的方向不重要(两个方向都必然起不来),**是不是用户自己选的**才重要。
|
||||
#[must_use]
|
||||
pub fn decide(mismatch: Option<JavaMismatch>, explicitly_chosen: bool) -> LaunchVerdict {
|
||||
match mismatch {
|
||||
Some(mismatch) if !explicitly_chosen => LaunchVerdict::Block(mismatch),
|
||||
_ => LaunchVerdict::Proceed,
|
||||
}
|
||||
}
|
||||
|
||||
/// 挑 + 判不符。
|
||||
#[must_use]
|
||||
pub fn resolve(candidates: &[JavaRuntime], required: &JavaRequirement) -> JavaResolution {
|
||||
@@ -75,8 +126,8 @@ pub fn resolve(candidates: &[JavaRuntime], required: &JavaRequirement) -> JavaRe
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve;
|
||||
use crate::game::java::mapping::{JavaRequirement, RequirementSource};
|
||||
use super::{decide, distance, resolve, LaunchVerdict};
|
||||
use crate::game::java::mapping::{JavaMismatch, JavaRequirement, RequirementSource};
|
||||
use crate::game::java::probe::{JavaOrigin, JavaRuntime, DEFAULT_MAJOR};
|
||||
|
||||
fn runtime(major: u32, origin: JavaOrigin) -> JavaRuntime {
|
||||
@@ -149,6 +200,36 @@ mod tests {
|
||||
assert!(picked.is_satisfied());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsatisfiable_candidates_are_ranked_by_distance_not_by_origin() {
|
||||
// 1.12.2(min=max=8):托管的 25 与 JAVA_HOME 的 17 都不满足要求。
|
||||
// 按来源排会选托管 25(rank 1 < 2),按版本距离应选 17(距离 9 vs 17)。
|
||||
let required = JavaRequirement::new(8, Some(8), RequirementSource::Table);
|
||||
let candidates = [
|
||||
runtime(25, JavaOrigin::Managed),
|
||||
runtime(17, JavaOrigin::JavaHome),
|
||||
];
|
||||
assert_eq!(
|
||||
resolve(&candidates, &required).major(),
|
||||
17,
|
||||
"全不满足时应按版本距离选 17,而不是按来源选托管的 25"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_old_candidates_are_ranked_by_distance() {
|
||||
// 需要 21+:托管 8 与 JAVA_HOME 17 都太老(距离 13 vs 4)。
|
||||
let candidates = [
|
||||
runtime(8, JavaOrigin::Managed),
|
||||
runtime(17, JavaOrigin::JavaHome),
|
||||
];
|
||||
assert_eq!(
|
||||
resolve(&candidates, &at_least(21)).major(),
|
||||
17,
|
||||
"都太老时应选最接近的那个,而不是托管目录里最老的那个"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_is_reported_when_nothing_fits() {
|
||||
let candidates = [runtime(8, JavaOrigin::Path)];
|
||||
@@ -157,4 +238,55 @@ mod tests {
|
||||
let mismatch = picked.mismatch.expect("应报不符");
|
||||
assert_eq!(mismatch.code(), "java_too_old");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distance_is_zero_inside_the_interval_and_positive_outside() {
|
||||
let legacy = JavaRequirement::new(8, Some(8), RequirementSource::Table);
|
||||
assert_eq!(distance(8, &legacy), 0);
|
||||
assert_eq!(distance(17, &legacy), 9);
|
||||
assert_eq!(distance(25, &legacy), 17);
|
||||
let modern = at_least(21);
|
||||
assert_eq!(distance(21, &modern), 0);
|
||||
assert_eq!(distance(25, &modern), 0);
|
||||
assert_eq!(distance(17, &modern), 4);
|
||||
// 区间 8–16(假想):16 与 9 都满足,只有 17 超出一档。
|
||||
let band = JavaRequirement::new(8, Some(16), RequirementSource::Table);
|
||||
assert_eq!(distance(16, &band), 0);
|
||||
assert_eq!(distance(17, &band), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_majors_rank_last() {
|
||||
let mut unknown = runtime(17, JavaOrigin::Managed);
|
||||
unknown.major = None;
|
||||
let candidates = [unknown, runtime(25, JavaOrigin::JavaHome)];
|
||||
let picked = resolve(&candidates, &at_least(21));
|
||||
assert_eq!(
|
||||
picked.runtime.map(|runtime| runtime.major),
|
||||
Some(Some(25)),
|
||||
"探不到主版本号的候选不能因为来源排名高就胜出"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_blocks_only_when_nobody_chose_that_java() {
|
||||
let mismatch = JavaMismatch::TooNew {
|
||||
maximum: 8,
|
||||
actual: 17,
|
||||
};
|
||||
assert_eq!(
|
||||
decide(Some(mismatch), false),
|
||||
LaunchVerdict::Block(mismatch)
|
||||
);
|
||||
assert_eq!(decide(Some(mismatch), true), LaunchVerdict::Proceed);
|
||||
assert_eq!(decide(None, false), LaunchVerdict::Proceed);
|
||||
assert_eq!(LaunchVerdict::Proceed.block(), None);
|
||||
assert_eq!(LaunchVerdict::Block(mismatch).block(), Some(mismatch));
|
||||
// 太老同样拦:装错方向不会改变「起不来」这个结局。
|
||||
let too_old = JavaMismatch::TooOld {
|
||||
required: 21,
|
||||
actual: 17,
|
||||
};
|
||||
assert_eq!(decide(Some(too_old), false).block(), Some(too_old));
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,19 @@ pub enum LaunchError {
|
||||
/// 底层错误。
|
||||
source: std::io::Error,
|
||||
},
|
||||
/// 看门狗到点**停止等待**(D6):进程仍在运行,我们只是不再等它了。
|
||||
Watchdog {
|
||||
/// 目标进程号(没有被结束)。
|
||||
pid: u32,
|
||||
/// 判据 id:`startup_timeout` / `shutdown_timeout`。
|
||||
reason: &'static str,
|
||||
/// 已经等了多久(毫秒)。
|
||||
waited_ms: u64,
|
||||
/// 判据上限(毫秒)。
|
||||
limit_ms: u64,
|
||||
/// 会话事件流(现场)。
|
||||
log_path: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for LaunchError {
|
||||
@@ -142,6 +155,28 @@ impl fmt::Display for LaunchError {
|
||||
Self::Kill { pid, source } => {
|
||||
write!(f, "结束进程树失败(pid {pid}):{source}")
|
||||
}
|
||||
Self::Watchdog {
|
||||
pid,
|
||||
reason,
|
||||
waited_ms,
|
||||
limit_ms,
|
||||
log_path,
|
||||
} => {
|
||||
let what = if *reason == "shutdown_timeout" {
|
||||
"游戏已开始关闭但一直没退出"
|
||||
} else {
|
||||
"启动后一直没有任何日志、也没出现游戏窗口"
|
||||
};
|
||||
write!(
|
||||
f,
|
||||
"{what}:等了 {:.0}s(上限 {:.0}s)就停止等待了。进程 {pid} 仍在运行、**没有被结束**\
|
||||
(可能只是加载得慢,也可能真卡住了);事件流见 {}。放宽上限:设环境变量 \
|
||||
ULL_LAUNCH_STARTUP_TIMEOUT_SECS / ULL_LAUNCH_SHUTDOWN_GRACE_SECS(秒,0 = 不限)",
|
||||
*waited_ms as f64 / 1000.0,
|
||||
*limit_ms as f64 / 1000.0,
|
||||
log_path.display()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,6 +189,7 @@ impl std::error::Error for LaunchError {
|
||||
Some(source)
|
||||
}
|
||||
Self::Session { .. } => None,
|
||||
Self::Watchdog { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,12 @@ pub const CRASH_KEYWORDS: &[&str] = &[
|
||||
/// 静默失败的存活阈值(毫秒):<30s 视为“没真正跑起来”。
|
||||
pub const SHORT_LIVED_MS: u64 = 30_000;
|
||||
|
||||
/// 「被外部结束」在 Windows 上的退出码。
|
||||
///
|
||||
/// 任务管理器「结束任务」与 `taskkill /F` 都走 `TerminateProcess(h, 1)`,进程侧只能看到 1;
|
||||
/// 真正的应用崩溃在 Windows 上给的是 NTSTATUS 异常码(`0xC0000005` 等),不会落在 1。
|
||||
pub const WINDOWS_TERMINATED_CODE: i32 = 1;
|
||||
|
||||
/// 退出分析的输入。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ExitSignals<'a> {
|
||||
@@ -139,6 +145,11 @@ pub struct ExitAnalysis {
|
||||
pub silent_failure: Option<SilentFailure>,
|
||||
/// Windows NTSTATUS 解释(例 `0xC0000005 ACCESS_VIOLATION`)。
|
||||
pub windows_status: Option<String>,
|
||||
/// 是否**疑似被外部结束**(任务管理器 / 强杀),而不是应用自己崩了(D10)。
|
||||
///
|
||||
/// 判据见 [`looks_externally_terminated`]:Windows 上退出码 1 且零错误输出。
|
||||
/// 与「我们杀的」区分开:那条路有 [`crate::launch::kill_registry`] 作据,是**确知**的。
|
||||
pub terminated_externally: bool,
|
||||
/// 是否应该进入诊断流水线(M15)。
|
||||
pub needs_diagnosis: bool,
|
||||
}
|
||||
@@ -156,6 +167,9 @@ impl ExitAnalysis {
|
||||
if let Some(status) = &self.windows_status {
|
||||
text.push_str(&format!(",{status}"));
|
||||
}
|
||||
if self.terminated_externally {
|
||||
text.push_str(",零错误输出:疑似被外部结束(任务管理器 / 强杀)");
|
||||
}
|
||||
if !self.crash_keywords.is_empty() {
|
||||
text.push_str(&format!(",崩溃关键字 {}", self.crash_keywords.len()));
|
||||
}
|
||||
@@ -186,6 +200,9 @@ pub fn analyze(signals: &ExitSignals<'_>) -> ExitAnalysis {
|
||||
ExitType::SigKill
|
||||
} else if signals.is_posix && signals.code == 143 {
|
||||
ExitType::SigTerm
|
||||
} else if looks_externally_terminated(signals, error_lines, &crash_keywords, &jvm_keywords) {
|
||||
// D10:任务管理器 / `taskkill /F` 给的就是「1 + 零错误输出」,不是应用错误。
|
||||
ExitType::Killed
|
||||
} else if signals.code != 0 || !crash_keywords.is_empty() {
|
||||
ExitType::ApplicationError
|
||||
} else {
|
||||
@@ -215,12 +232,42 @@ pub fn analyze(signals: &ExitSignals<'_>) -> ExitAnalysis {
|
||||
jvm_keywords,
|
||||
silent_failure,
|
||||
windows_status: windows_status_name(signals.code),
|
||||
terminated_externally: exit_type == ExitType::Killed
|
||||
&& !signals.killed
|
||||
&& !signals.interrupted,
|
||||
needs_diagnosis: exit_type.is_abnormal()
|
||||
|| silent_failure.is_some()
|
||||
|| !crash_keywords.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 外部结束的判据(D10)。
|
||||
///
|
||||
/// 三条同时成立才算:
|
||||
///
|
||||
/// 1. **不是我们杀的**(`signals.killed`)——那条路有 [`crate::launch::kill_registry`] 作据;
|
||||
/// 2. Windows 上退出码 [`WINDOWS_TERMINATED_CODE`](`TerminateProcess(h, 1)` 的痕迹);
|
||||
/// 3. **零错误输出**:没有错误行、没有崩溃关键字、没有 JVM 失败关键字。
|
||||
///
|
||||
/// 「1 + 一句话的报错都没有」不属于应用崩溃:应用崩在 Windows 上给 NTSTATUS 异常码,
|
||||
/// JVM 起不来会打 `Could not create the Java Virtual Machine.`,`System.exit(1)` 之前
|
||||
/// 一般也有日志。剩下最可能的解释就是「被外面按了结束」——挂死之后任务管理器强杀正是这种形态。
|
||||
#[must_use]
|
||||
pub fn looks_externally_terminated(
|
||||
signals: &ExitSignals<'_>,
|
||||
error_lines: usize,
|
||||
crash_keywords: &[String],
|
||||
jvm_keywords: &[String],
|
||||
) -> bool {
|
||||
!signals.killed
|
||||
&& !signals.interrupted
|
||||
&& !signals.is_posix
|
||||
&& signals.code == WINDOWS_TERMINATED_CODE
|
||||
&& error_lines == 0
|
||||
&& crash_keywords.is_empty()
|
||||
&& jvm_keywords.is_empty()
|
||||
}
|
||||
|
||||
/// Windows NTSTATUS → 解释。
|
||||
pub fn windows_status_name(code: i32) -> Option<String> {
|
||||
let (hex, name) = match code {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! 「这个 pid 是我们主动结束的」的进程内登记簿(D10)。
|
||||
//!
|
||||
//! M208 实机:挂死的 1.12.2 被强杀后记成「退出码 1(application_error)」,而那一局
|
||||
//! `error_lines 0`。问题不在判定表,在**信息丢失**:谁发的结束请求只有调用方知道,
|
||||
//! 进程自己只会回一个 1。
|
||||
//!
|
||||
//! 所以把「我们杀过它」变成一条有据可查的事实:
|
||||
//!
|
||||
//! - 发送侧([`crate::launch::tree_kill`])在发信号前登记 pid,**所有**结束路径都经过那里
|
||||
//! (`RunningProcess::stop` / GUI 的 `host::launch::stop` / 看门狗兜底),不需要每个调用点
|
||||
//! 自己记得标记;
|
||||
//! - 判定侧([`crate::launch::launch_session`])在收尾时取走登记,据此判
|
||||
//! [`ExitType::Killed`](crate::launch::exit_analysis::ExitType::Killed) 而不是应用错误。
|
||||
//!
|
||||
//! 两条纪律:
|
||||
//!
|
||||
//! 1. [`take`] 是**取走**语义(判完就忘):pid 会被复用,留着旧登记会把别的进程误判成
|
||||
//! 「我们杀的」;
|
||||
//! 2. 登记表有上限([`MAX_TRACKED`]),登记了却没人消费(进程自己先退了)的条目按 FIFO 淘汰,
|
||||
//! 不随运行时长无限增长。
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// 最多同时记住多少个 pid。
|
||||
pub const MAX_TRACKED: usize = 64;
|
||||
|
||||
fn registry() -> &'static Mutex<VecDeque<u32>> {
|
||||
static REGISTRY: OnceLock<Mutex<VecDeque<u32>>> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| Mutex::new(VecDeque::new()))
|
||||
}
|
||||
|
||||
/// 登记「我们正在结束 `pid`」(重复登记不叠加)。
|
||||
pub fn note(pid: u32) {
|
||||
if pid == 0 {
|
||||
return;
|
||||
}
|
||||
let Ok(mut guard) = registry().lock() else {
|
||||
return;
|
||||
};
|
||||
if guard.contains(&pid) {
|
||||
return;
|
||||
}
|
||||
while guard.len() >= MAX_TRACKED {
|
||||
guard.pop_front();
|
||||
}
|
||||
guard.push_back(pid);
|
||||
}
|
||||
|
||||
/// 取走登记,返回此前是否登记过。
|
||||
pub fn take(pid: u32) -> bool {
|
||||
let Ok(mut guard) = registry().lock() else {
|
||||
return false;
|
||||
};
|
||||
match guard.iter().position(|tracked| *tracked == pid) {
|
||||
Some(index) => {
|
||||
guard.remove(index);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 撤销登记(结束动作没送达、进程其实还在时用,避免下一次误判)。
|
||||
pub fn forget(pid: u32) {
|
||||
let Ok(mut guard) = registry().lock() else {
|
||||
return;
|
||||
};
|
||||
if let Some(index) = guard.iter().position(|tracked| *tracked == pid) {
|
||||
guard.remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{forget, note, take, MAX_TRACKED};
|
||||
|
||||
#[test]
|
||||
fn noted_pid_is_taken_exactly_once() {
|
||||
note(4242);
|
||||
assert!(take(4242), "登记过的 pid 应判成我们杀的");
|
||||
assert!(!take(4242), "取走语义:判完就忘,避免 pid 复用误伤");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_a_pid_clears_the_note() {
|
||||
note(4243);
|
||||
forget(4243);
|
||||
assert!(!take(4243));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_zero_is_never_tracked() {
|
||||
// 0 在 POSIX 上是「当前进程组」,绝不能出现在登记表里。
|
||||
note(0);
|
||||
assert!(!take(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_is_bounded_and_drops_the_oldest() {
|
||||
let base = 900_000;
|
||||
for offset in 0..u32::try_from(MAX_TRACKED).unwrap() + 5 {
|
||||
note(base + offset);
|
||||
}
|
||||
assert!(!take(base), "最早的一条应被 FIFO 淘汰");
|
||||
assert!(take(base + 5), "上限之后的条目仍在表里");
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,32 @@
|
||||
//!
|
||||
//! 一次启动的生命周期(`DevDocs/PlanDoc/06-launch-and-diagnostics.md` §1 的第 7~10 阶段):
|
||||
//! `spawn` → 用户可能 [`LaunchSession::stop`] → [`LaunchSession::finish`]
|
||||
//! (等进程 → 等日志泵(有预算)→ 退出分析 → 扫崩溃现场 → 诊断挂钩 → 写事件 + 索引)。
|
||||
//! (**有界**等进程 → 等日志泵(有预算)→ 退出分析 → 扫崩溃现场 → 诊断挂钩 → 写事件 + 索引)。
|
||||
//!
|
||||
//! 「有界」是 D6:等进程那一步会按 [`crate::launch::startup_watch`] 的判据停下(窗口从未出现 /
|
||||
//! 已出现关闭标记),到点**不杀进程**,只结束本次会话并报 [`crate::launch::error::LaunchError::Watchdog`]。
|
||||
//!
|
||||
//! 结果是 [`SessionHandle`](拆到 `session_handle`:那边是纯数据 + 展示)。
|
||||
|
||||
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::error::{LaunchError, Result};
|
||||
use crate::launch::exit_analysis::{self, ExitAnalysis, ExitSignals};
|
||||
use crate::launch::kill_registry;
|
||||
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;
|
||||
use crate::launch::startup_watch::{StartupWatch, WatchVerdict};
|
||||
|
||||
pub use crate::launch::session_handle::SessionHandle;
|
||||
|
||||
/// 一次运行中的启动会话。
|
||||
pub struct LaunchSession {
|
||||
@@ -49,7 +55,16 @@ impl std::fmt::Debug for LaunchSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// 有界等待的结果(D6)。
|
||||
enum WaitOutcome {
|
||||
/// 进程已退出。
|
||||
Exited(std::process::ExitStatus),
|
||||
/// 看门狗判出「活着但没动静」:不杀进程,只是不再等。
|
||||
GaveUp(WatchVerdict),
|
||||
}
|
||||
|
||||
impl LaunchSession {
|
||||
/// 构造一次会话。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn new(
|
||||
config: LauncherConfig,
|
||||
@@ -126,9 +141,16 @@ impl LaunchSession {
|
||||
self.process.stop()
|
||||
}
|
||||
|
||||
/// 收尾:等进程 → 等日志泵 → 退出分析 → 现场扫描 → 诊断 → 事件 + 索引。
|
||||
/// 收尾:**有界地**等进程 → 等日志泵 → 退出分析 → 现场扫描 → 诊断 → 事件 + 索引。
|
||||
///
|
||||
/// 「有界」是 D6:到点([`Self::wait_bounded`] 的判据)只**停止等待**,绝不杀进程——
|
||||
/// 用户的加载进度不该被启动器清掉。走那条路时先写 `session_end`(`still_running`)
|
||||
/// 与一行 `launcher.log`,再返回 [`LaunchError::Watchdog`]。
|
||||
pub fn finish(mut self) -> Result<SessionHandle> {
|
||||
let status = self.process.wait()?;
|
||||
let status = match self.wait_bounded()? {
|
||||
WaitOutcome::Exited(status) => status,
|
||||
WaitOutcome::GaveUp(verdict) => return self.abandon(verdict),
|
||||
};
|
||||
let code = process::exit_code(&status);
|
||||
// uptime 必须在等日志泵之前取(泵的等待预算不算进游戏存活时间)。
|
||||
let uptime_ms = self.process.uptime_ms();
|
||||
@@ -137,7 +159,10 @@ impl LaunchSession {
|
||||
let lines = self.process.lines();
|
||||
|
||||
let mut signals = ExitSignals::new(code, uptime_ms, &lines);
|
||||
signals.killed = self.killed;
|
||||
// D10:判定「是不是我们杀的」有两个来源——本会话自己的 `stop()`,以及发送侧
|
||||
// (`tree_kill`)留下的登记。GUI 从别的线程 kill 的那条路只经过后者。
|
||||
let killed = self.killed || kill_registry::take(self.pid);
|
||||
signals.killed = killed;
|
||||
signals.is_posix = self.config.platform.os.is_posix();
|
||||
let exit = exit_analysis::analyze(&signals);
|
||||
|
||||
@@ -164,7 +189,7 @@ impl LaunchSession {
|
||||
// M211/D3:崩溃结论只认真实证据(权威现场文件 / 非 0 退出 + 错误行),不再由
|
||||
// 「有没有 latest.log」推出——那是每局都有的文件。
|
||||
let crash_evidence = crash_scan::has_crash_evidence(&artifacts, &exit);
|
||||
let end_reason = if self.killed {
|
||||
let end_reason = if killed {
|
||||
EndReason::Killed
|
||||
} else {
|
||||
EndReason::Exit
|
||||
@@ -199,10 +224,10 @@ impl LaunchSession {
|
||||
event_write_errors.push("会话写入器不可用(锁中毒)".to_string());
|
||||
}
|
||||
|
||||
Ok(SessionHandle {
|
||||
session_id: self.session_id,
|
||||
log_path: self.log_path,
|
||||
command: self.command,
|
||||
Ok(SessionHandle::new(
|
||||
self.session_id,
|
||||
self.log_path,
|
||||
self.command,
|
||||
redacted_argv,
|
||||
exit,
|
||||
diagnostics,
|
||||
@@ -211,6 +236,64 @@ impl LaunchSession {
|
||||
uptime_ms,
|
||||
pump_stalled,
|
||||
event_write_errors,
|
||||
))
|
||||
}
|
||||
|
||||
/// 有界等待:要么进程退出,要么看门狗判出「活着但没动静」(D6)。
|
||||
///
|
||||
/// 判据在 [`StartupWatch`](纯逻辑,`startup_watch` 的测试钉住);这里只把三个信号喂进去:
|
||||
/// 进程是否已退出、窗口是否**出现过**、日志里是否出现了关闭标记。
|
||||
fn wait_bounded(&mut self) -> Result<WaitOutcome> {
|
||||
let limits = self.config.process;
|
||||
let mut watch = StartupWatch::new(limits.startup_timeout, limits.shutdown_grace);
|
||||
let started = self.process.started_at();
|
||||
loop {
|
||||
if let Some(status) = self.process.try_wait()? {
|
||||
return Ok(WaitOutcome::Exited(status));
|
||||
}
|
||||
if self.sink.window_detected() {
|
||||
watch.mark_window();
|
||||
}
|
||||
if let Some(at) = self.sink.last_line_at() {
|
||||
watch.mark_activity(at.saturating_duration_since(started));
|
||||
}
|
||||
if let Some(at) = self.sink.shutdown_seen_at() {
|
||||
watch.mark_shutdown(at.saturating_duration_since(started));
|
||||
}
|
||||
let verdict = watch.verdict(started.elapsed());
|
||||
if !verdict.is_wait() {
|
||||
return Ok(WaitOutcome::GaveUp(verdict));
|
||||
}
|
||||
std::thread::sleep(process::POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
/// 放弃等待:**不杀进程**,把现场写清楚,返回结构化错误。
|
||||
///
|
||||
/// 三条落点:① `launcher.log` 一行(含判据、上限、pid);② 会话事件流写
|
||||
/// `session_end`(`still_running`)并追加索引,历史里看得见这一局;③ 错误本身带
|
||||
/// 会话路径与可调的环境变量。
|
||||
fn abandon(self, verdict: WatchVerdict) -> Result<SessionHandle> {
|
||||
let pid = self.pid;
|
||||
let spent_ms = verdict.waited_ms();
|
||||
crate::launch::launcher_log::record(
|
||||
"launch",
|
||||
&format!(
|
||||
"instance={} session={} 看门狗:{} ⇒ 停止等待(pid {pid} 仍在运行,未结束)",
|
||||
self.request.instance.id,
|
||||
self.session_id,
|
||||
verdict.describe()
|
||||
),
|
||||
);
|
||||
let _ = self
|
||||
.sink
|
||||
.with_writer(|writer| writer.finish(EndReason::StillRunning));
|
||||
Err(LaunchError::Watchdog {
|
||||
pid,
|
||||
reason: verdict.code(),
|
||||
waited_ms: spent_ms,
|
||||
limit_ms: verdict.limit_ms(),
|
||||
log_path: self.log_path,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -258,90 +341,3 @@ impl LaunchSession {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次启动的结果。
|
||||
#[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
|
||||
}
|
||||
|
||||
/// 结局标签,与 GUI 的 `LogOutcome` 同词(`Ok` / `Crashed` / `SilentFail`)。
|
||||
///
|
||||
/// `launcher.log` 里两条路(GUI / `ull launch`)都写这一套词,见
|
||||
/// [`crate::launch::launcher_log::launch_end_line`]。
|
||||
pub fn outcome_label(&self) -> &'static str {
|
||||
if self.exit.silent_failure.is_some() {
|
||||
"SilentFail"
|
||||
} else if self.succeeded() {
|
||||
"Ok"
|
||||
} else {
|
||||
"Crashed"
|
||||
}
|
||||
}
|
||||
|
||||
/// 一句话摘要。
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,26 @@ impl LauncherConfig {
|
||||
self.launcher_version = version.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// 让两个看门狗时长按环境变量覆盖(D6 的「可配」出口,宿主显式要求时生效)。
|
||||
#[must_use]
|
||||
pub fn with_watchdog_env(mut self) -> Self {
|
||||
self.process = self.process.with_env_overrides();
|
||||
self
|
||||
}
|
||||
|
||||
/// 一行「看门狗生效值」(D6:卡死之后第一个要看的就是「等了多久才放弃」)。
|
||||
#[must_use]
|
||||
pub fn watchdog_summary(&self) -> String {
|
||||
let startup = self.process.startup_timeout.as_secs();
|
||||
let grace = self.process.shutdown_grace.as_secs();
|
||||
format!(
|
||||
"看门狗:连续 {startup}s 没日志也没窗口就停止等待;出现关闭标记后 {grace}s 仍未退出也停止等待\
|
||||
(0 = 不限;停止等待**不结束进程**,可用 {} / {} 调整)",
|
||||
crate::launch::process::STARTUP_TIMEOUT_ENV,
|
||||
crate::launch::process::SHUTDOWN_GRACE_ENV,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `--dry-run` 的输出(D18):完整命令行,但**不 spawn、不写会话文件**。
|
||||
@@ -248,6 +268,9 @@ impl Launcher {
|
||||
}
|
||||
|
||||
/// 启动并阻塞到进程结束(P0 主入口)。
|
||||
///
|
||||
/// 「阻塞」有界(D6):看门狗判出「活着但没动静」时返回 [`crate::launch::error::LaunchError::Watchdog`],
|
||||
/// 进程**不会**被结束。
|
||||
pub fn launch(&self, request: &LaunchRequest) -> Result<SessionHandle> {
|
||||
self.spawn(request)?.finish()
|
||||
}
|
||||
|
||||
@@ -22,6 +22,15 @@
|
||||
//! 2. 日志泵 join 有预算([`watchdog`]),进程树三段式回收([`tree_kill`],P1 换 Job Object);
|
||||
//! 3. 窗口出现检测带启发式名称/耗时/证据([`window_detect`])。
|
||||
//!
|
||||
//! # 两条实机修正(M208 台账)
|
||||
//!
|
||||
//! - **D6 有界等待**:进程也可能「活着但没动静」。判据在 [`startup_watch`]
|
||||
//! (未见过窗口 / 已出现关闭标记),到点只**停止等待**并返回
|
||||
//! [`LaunchError::Watchdog`]——绝不杀进程,用户的加载进度不该被启动器清掉;
|
||||
//! - **D10 结束归属**:谁结束的进程只有调用方知道,所以 [`tree_kill`] 送达即登记
|
||||
//! ([`kill_registry`]),退出分析据此判「被终止」而不是「应用错误」;外部强杀
|
||||
//! (任务管理器)走[`exit_analysis::looks_externally_terminated`] 的 Windows 判据。
|
||||
//!
|
||||
//! 零 UI 依赖:本模块只产出结构化事件与纯数据,不 import 任何 UI 类型。
|
||||
|
||||
pub mod adapter;
|
||||
@@ -38,6 +47,7 @@ pub mod exit_analysis;
|
||||
pub mod game_args;
|
||||
pub mod game_version;
|
||||
pub mod jvm_args;
|
||||
pub mod kill_registry;
|
||||
pub mod launch_session;
|
||||
pub mod launcher;
|
||||
pub mod launcher_log;
|
||||
@@ -51,11 +61,13 @@ pub mod pump;
|
||||
pub mod redact;
|
||||
pub mod request;
|
||||
pub mod session;
|
||||
pub mod session_handle;
|
||||
pub mod session_index;
|
||||
pub mod session_meta;
|
||||
pub mod session_payload;
|
||||
pub mod session_sink;
|
||||
pub mod sink;
|
||||
pub mod startup_watch;
|
||||
pub mod time;
|
||||
pub mod tree_kill;
|
||||
pub mod watchdog;
|
||||
@@ -78,3 +90,4 @@ pub use request::LaunchRequest;
|
||||
pub use session::{EndReason, SessionMeta, SessionSink, SessionWriter};
|
||||
pub use session_index::SessionIndexEntry;
|
||||
pub use sink::{CompositeSink, CountingSink, FnSink};
|
||||
pub use startup_watch::{StartupWatch, WatchVerdict};
|
||||
@@ -208,6 +208,21 @@ impl NativesReport {
|
||||
self.dir.display()
|
||||
)
|
||||
}
|
||||
|
||||
/// `--json` 的 `natives` 块(`ull launch --dry-run` 用;字段名是接口的一部分)。
|
||||
#[must_use]
|
||||
pub fn to_json(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"dir": self.dir.display().to_string(),
|
||||
"files": self.files,
|
||||
"skipped": self.skipped,
|
||||
"ignored": self.ignored,
|
||||
"jars": self.jars,
|
||||
"custom": self.custom,
|
||||
"up_to_date": self.up_to_date,
|
||||
"era": self.era.as_str(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 解压失败原因。
|
||||
|
||||
@@ -19,8 +19,20 @@ use crate::launch::watchdog::{self, PumpVerdict, WatchdogConfig};
|
||||
|
||||
/// 强制结束后等进程消失的预算。
|
||||
const FORCE_KILL_BUDGET: Duration = Duration::from_secs(5);
|
||||
/// 轮询进程状态的间隔。
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(20);
|
||||
/// 轮询进程状态的间隔(有界等待也用这个)。
|
||||
pub const POLL_INTERVAL: Duration = Duration::from_millis(20);
|
||||
|
||||
/// 默认启动预算:spawn 后**连续这么久既没有日志输出、也没见到游戏窗口**就停止等待(D6)。
|
||||
///
|
||||
/// 判据是「静默」不是「等了多久」:还在打日志说明它还在加载(大整合包首次启动三分钟以上
|
||||
/// 是常态,那期间日志一直在刷),所以这里按「一声不响」计量,宁松不紧。
|
||||
pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
/// 默认关闭宽限:日志里出现关闭标记后这么久还没退出,就认为卡在关闭(D6)。
|
||||
pub const DEFAULT_SHUTDOWN_GRACE: Duration = Duration::from_secs(60);
|
||||
/// 覆盖启动预算的环境变量(单位秒,`0` = 不限)。
|
||||
pub const STARTUP_TIMEOUT_ENV: &str = "ULL_LAUNCH_STARTUP_TIMEOUT_SECS";
|
||||
/// 覆盖关闭宽限的环境变量(单位秒,`0` = 不限)。
|
||||
pub const SHUTDOWN_GRACE_ENV: &str = "ULL_LAUNCH_SHUTDOWN_GRACE_SECS";
|
||||
|
||||
/// 进程管理配置。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -31,6 +43,13 @@ pub struct ProcessConfig {
|
||||
pub graceful_stop: Duration,
|
||||
/// 环形缓冲配置。
|
||||
pub buffer: RingBufferConfig,
|
||||
/// 未见游戏窗口的**静默**预算(D6;`0` = 不判)。
|
||||
///
|
||||
/// 判据是「连续这么久没有任何日志输出、也没见到窗口」——还在打日志就不算卡死,
|
||||
/// 大整合包慢慢加载不会被判进来。
|
||||
pub startup_timeout: Duration,
|
||||
/// 出现关闭标记后仍不退出的宽限(D6;`0` = 不判)。
|
||||
pub shutdown_grace: Duration,
|
||||
}
|
||||
|
||||
impl Default for ProcessConfig {
|
||||
@@ -39,10 +58,43 @@ impl Default for ProcessConfig {
|
||||
watchdog: WatchdogConfig::default(),
|
||||
graceful_stop: Duration::from_secs(3),
|
||||
buffer: RingBufferConfig::default(),
|
||||
startup_timeout: DEFAULT_STARTUP_TIMEOUT,
|
||||
shutdown_grace: DEFAULT_SHUTDOWN_GRACE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProcessConfig {
|
||||
/// 按环境变量覆盖两个看门狗时长(D6 的「可配」出口;不设就等于默认值)。
|
||||
///
|
||||
/// 放在这里而不是 `default()`:默认值必须是纯的(测试与嵌入方都吃它),
|
||||
/// 环境变量只在宿主明确要求时生效(CLI 的 `LauncherConfig::with_watchdog_env`)。
|
||||
#[must_use]
|
||||
pub fn with_env_overrides(mut self) -> Self {
|
||||
if let Some(value) = env_seconds(STARTUP_TIMEOUT_ENV) {
|
||||
self.startup_timeout = value;
|
||||
}
|
||||
if let Some(value) = env_seconds(SHUTDOWN_GRACE_ENV) {
|
||||
self.shutdown_grace = value;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 读一个「秒数」环境变量(空值/非数字/溢出都当没设)。
|
||||
fn env_seconds(name: &str) -> Option<Duration> {
|
||||
parse_seconds(&std::env::var(name).ok()?)
|
||||
}
|
||||
|
||||
/// 解析秒数(`"120"` → 120s,`"0"` → 0s = 关闭该判据;其余一律 `None`)。
|
||||
fn parse_seconds(raw: &str) -> Option<Duration> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
trimmed.parse::<u64>().ok().map(Duration::from_secs)
|
||||
}
|
||||
|
||||
/// 已启动的游戏进程。
|
||||
#[derive(Debug)]
|
||||
pub struct RunningProcess {
|
||||
@@ -147,6 +199,15 @@ impl RunningProcess {
|
||||
})
|
||||
}
|
||||
|
||||
/// 非阻塞取退出状态(`None` = 还在跑)。有界等待(D6)靠它轮询。
|
||||
pub fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
|
||||
self.child.try_wait().map_err(|source| LaunchError::Io {
|
||||
op: "try_wait_child",
|
||||
path: PathBuf::from(self.pid.to_string()),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// 进程退出后,按预算收尾日志泵。
|
||||
pub fn supervise(&mut self) -> PumpVerdict {
|
||||
watchdog::wait_for_pumps(&mut self.pump, &self.config.watchdog)
|
||||
@@ -229,3 +290,28 @@ pub fn exit_code(status: &ExitStatus) -> i32 {
|
||||
}
|
||||
-1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_seconds, ProcessConfig, DEFAULT_SHUTDOWN_GRACE, DEFAULT_STARTUP_TIMEOUT};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn defaults_are_real_values_not_disabled() {
|
||||
let config = ProcessConfig::default();
|
||||
assert_eq!(config.startup_timeout, DEFAULT_STARTUP_TIMEOUT);
|
||||
assert_eq!(config.shutdown_grace, DEFAULT_SHUTDOWN_GRACE);
|
||||
assert!(!config.startup_timeout.is_zero(), "默认值不能等于关掉");
|
||||
assert!(!config.shutdown_grace.is_zero());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seconds_parser_accepts_plain_numbers_and_zero() {
|
||||
assert_eq!(parse_seconds("120"), Some(Duration::from_secs(120)));
|
||||
assert_eq!(parse_seconds(" 45 "), Some(Duration::from_secs(45)));
|
||||
assert_eq!(parse_seconds("0"), Some(Duration::ZERO));
|
||||
assert_eq!(parse_seconds(""), None);
|
||||
assert_eq!(parse_seconds("abc"), None);
|
||||
assert_eq!(parse_seconds("-5"), None);
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,8 @@ pub enum EndReason {
|
||||
AssemblyFailed,
|
||||
/// 进程派生失败。
|
||||
SpawnFailed,
|
||||
/// 看门狗到点停止等待,进程仍在运行(D6:不杀进程,只结束本次会话)。
|
||||
StillRunning,
|
||||
}
|
||||
|
||||
impl EndReason {
|
||||
@@ -104,6 +106,7 @@ impl EndReason {
|
||||
Self::Killed => "killed",
|
||||
Self::AssemblyFailed => "assembly_failed",
|
||||
Self::SpawnFailed => "spawn_failed",
|
||||
Self::StillRunning => "still_running",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! 一次启动的结果([`SessionHandle`])。
|
||||
//!
|
||||
//! 从 `launch_session` 拆出:那边是「跑一局 + 收尾」的流水线,这里是**纯数据 + 展示**。
|
||||
//! 拆开的直接原因是行数——`launch_session` 加上 D6 的有界等待后越过了 400 行红线。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::launch::args_table::AssembledCommand;
|
||||
use crate::launch::crash_scan::CrashArtifact;
|
||||
use crate::launch::exit_analysis::ExitAnalysis;
|
||||
use crate::launch::session_index::SessionIndexEntry;
|
||||
|
||||
/// 一次启动的结果。
|
||||
#[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 {
|
||||
/// 构造(`redacted_argv` 只在这里赋值,外部拿不到明文 argv 的那条路)。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn new(
|
||||
session_id: String,
|
||||
log_path: PathBuf,
|
||||
command: AssembledCommand,
|
||||
redacted_argv: Vec<String>,
|
||||
exit: ExitAnalysis,
|
||||
diagnostics: Option<Value>,
|
||||
index: Option<SessionIndexEntry>,
|
||||
artifacts: Vec<CrashArtifact>,
|
||||
uptime_ms: u64,
|
||||
pump_stalled: bool,
|
||||
event_write_errors: Vec<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
session_id,
|
||||
log_path,
|
||||
command,
|
||||
redacted_argv,
|
||||
exit,
|
||||
diagnostics,
|
||||
index,
|
||||
artifacts,
|
||||
uptime_ms,
|
||||
pump_stalled,
|
||||
event_write_errors,
|
||||
}
|
||||
}
|
||||
|
||||
/// 脱敏后的完整 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
|
||||
}
|
||||
|
||||
/// 结局标签,与 GUI 的 `LogOutcome` 同词(`Ok` / `Crashed` / `SilentFail`)。
|
||||
///
|
||||
/// `launcher.log` 里两条路(GUI / `ull launch`)都写这一套词,见
|
||||
/// [`crate::launch::launcher_log::launch_end_line`]。
|
||||
pub fn outcome_label(&self) -> &'static str {
|
||||
if self.exit.silent_failure.is_some() {
|
||||
"SilentFail"
|
||||
} else if self.succeeded() {
|
||||
"Ok"
|
||||
} else {
|
||||
"Crashed"
|
||||
}
|
||||
}
|
||||
|
||||
/// 一句话摘要。
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -4,32 +4,34 @@
|
||||
//! 也避免单文件超过 400 行的硬约束。
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::launch::pump::{LineSink, LogLine};
|
||||
use crate::launch::session::SessionWriter;
|
||||
use crate::launch::window_detect::WindowDetector;
|
||||
use crate::launch::window_detect::{shutdown_marker, WindowDetector};
|
||||
|
||||
/// 会话日志 sink。
|
||||
#[derive(Debug)]
|
||||
pub struct SessionSink {
|
||||
writer: Mutex<SessionWriter>,
|
||||
detector: Mutex<WindowDetector>,
|
||||
shutdown_at: Mutex<Option<Instant>>,
|
||||
last_line_at: Mutex<Option<Instant>>,
|
||||
}
|
||||
|
||||
impl SessionSink {
|
||||
/// 以进程启动时刻为窗口检测起点。
|
||||
pub fn new(writer: SessionWriter) -> Self {
|
||||
Self {
|
||||
writer: Mutex::new(writer),
|
||||
detector: Mutex::new(WindowDetector::new()),
|
||||
}
|
||||
Self::with_start(writer, Instant::now())
|
||||
}
|
||||
|
||||
/// 指定窗口检测计时起点(进程 spawn 时刻)。
|
||||
pub fn with_start(writer: SessionWriter, started_at: std::time::Instant) -> Self {
|
||||
pub fn with_start(writer: SessionWriter, started_at: Instant) -> Self {
|
||||
Self {
|
||||
writer: Mutex::new(writer),
|
||||
detector: Mutex::new(WindowDetector::with_start(started_at)),
|
||||
shutdown_at: Mutex::new(None),
|
||||
last_line_at: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +47,16 @@ impl SessionSink {
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 第一次见到关闭标记(游戏自己说要退出)的时刻(D6)。
|
||||
pub fn shutdown_seen_at(&self) -> Option<Instant> {
|
||||
self.shutdown_at.lock().ok().and_then(|guard| *guard)
|
||||
}
|
||||
|
||||
/// 最后一行的时刻(D6:还在打日志就说明还在加载,不算卡死)。
|
||||
pub fn last_line_at(&self) -> Option<Instant> {
|
||||
self.last_line_at.lock().ok().and_then(|guard| *guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl LineSink for SessionSink {
|
||||
@@ -53,9 +65,22 @@ impl LineSink for SessionSink {
|
||||
Ok(mut detector) => detector.observe(&line.text),
|
||||
Err(_) => None,
|
||||
};
|
||||
if let Ok(mut guard) = self.last_line_at.lock() {
|
||||
*guard = Some(Instant::now());
|
||||
}
|
||||
if shutdown_marker(&line.text).is_some() {
|
||||
if let Ok(mut guard) = self.shutdown_at.lock() {
|
||||
guard.get_or_insert_with(Instant::now);
|
||||
}
|
||||
}
|
||||
let Ok(mut writer) = self.writer.lock() else {
|
||||
return;
|
||||
};
|
||||
// 会话已经收尾(看门狗放弃等待后进程还在打日志):不再往事件流里追加,
|
||||
// 否则 `session_end` 之后会出现孤儿行。
|
||||
if writer.is_finished() {
|
||||
return;
|
||||
}
|
||||
if let Some(detection) = detection {
|
||||
let _ = writer.window_detected(&detection);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
//! 启动看门狗:把「还在慢慢加载」与「活着但没动静」分开(D6)。
|
||||
//!
|
||||
//! M208 实机:1.12.2 关窗后 `java.exe` 仍存活、CPU 继续涨,`ull launch` 阻塞 >2 分钟,
|
||||
//! 用户视角就是启动器卡死。`watchdog.rs` 只管**日志泵**的预算,进程本身是无限等的。
|
||||
//!
|
||||
//! 这里只做判据(纯逻辑,不碰时钟也不碰进程),两条互不干扰:
|
||||
//!
|
||||
//! 1. **窗口从未出现**:进程起来后 `startup_limit` 内**既没见到游戏窗口、也没有任何新日志**,
|
||||
//! ⇒ `startup_timeout`。窗口一旦出现过,这条**永久失效**——大整合包首次启动慢是正常现象。
|
||||
//! 只按「多久没窗口」判会把慢加载判死,所以判据是**静默**:还在打日志就说明它还在干活。
|
||||
//! 2. **已经在关闭**:日志里出现关闭标记(`Stopping!` 等)后 `shutdown_limit` 仍不退出
|
||||
//! ⇒ `shutdown_timeout`。这条只在游戏自己说要退出之后才生效,正是 D6 那个「关窗后挂死」。
|
||||
//!
|
||||
//! 两个上限都有默认值、都可配([`crate::launch::process::ProcessConfig`]),也可以关掉(`0`)。
|
||||
//! 判出超时**不等于**要杀进程:调用方只停止等待,见
|
||||
//! [`crate::launch::launch_session::LaunchSession::finish`]。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// 「还要不要继续等」。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WatchVerdict {
|
||||
/// 继续等。
|
||||
Wait,
|
||||
/// 窗口从未出现、且连续这么久没有任何日志输出。
|
||||
StartupTimeout {
|
||||
/// 已经静默了多久(从最后一行日志算)。
|
||||
waited: Duration,
|
||||
/// 配置的静默预算。
|
||||
limit: Duration,
|
||||
},
|
||||
/// 已出现关闭标记,且超出宽限仍未退出。
|
||||
ShutdownTimeout {
|
||||
/// 从关闭标记到现在等了多久。
|
||||
waited: Duration,
|
||||
/// 配置的宽限。
|
||||
limit: Duration,
|
||||
},
|
||||
}
|
||||
|
||||
impl WatchVerdict {
|
||||
/// 是否应当继续等。
|
||||
#[must_use]
|
||||
pub fn is_wait(self) -> bool {
|
||||
self == Self::Wait
|
||||
}
|
||||
|
||||
/// 稳定字符串 id(`launcher.log` / `--json` / 错误码用)。
|
||||
#[must_use]
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::Wait => "wait",
|
||||
Self::StartupTimeout { .. } => "startup_timeout",
|
||||
Self::ShutdownTimeout { .. } => "shutdown_timeout",
|
||||
}
|
||||
}
|
||||
|
||||
/// 判据上限(毫秒;`Wait` 为 0)。
|
||||
#[must_use]
|
||||
pub fn limit_ms(self) -> u64 {
|
||||
let limit = match self {
|
||||
Self::Wait => Duration::ZERO,
|
||||
Self::StartupTimeout { limit, .. } | Self::ShutdownTimeout { limit, .. } => limit,
|
||||
};
|
||||
millis(limit)
|
||||
}
|
||||
|
||||
/// 已经等了多久(毫秒;`Wait` 为 0)。
|
||||
#[must_use]
|
||||
pub fn waited_ms(self) -> u64 {
|
||||
match self {
|
||||
Self::Wait => 0,
|
||||
Self::StartupTimeout { waited, .. } | Self::ShutdownTimeout { waited, .. } => {
|
||||
millis(waited)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 人读一行。
|
||||
#[must_use]
|
||||
pub fn describe(self) -> String {
|
||||
match self {
|
||||
Self::Wait => "继续等待".to_string(),
|
||||
Self::StartupTimeout { waited, limit } => format!(
|
||||
"启动 {:.1}s 没有任何日志输出、也没见到游戏窗口(静默预算 {:.1}s)",
|
||||
seconds(waited),
|
||||
seconds(limit)
|
||||
),
|
||||
Self::ShutdownTimeout { waited, limit } => format!(
|
||||
"已开始关闭但 {:.1}s 仍未退出(宽限 {:.1}s)",
|
||||
seconds(waited),
|
||||
seconds(limit)
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn seconds(value: Duration) -> f64 {
|
||||
value.as_secs_f64()
|
||||
}
|
||||
|
||||
fn millis(value: Duration) -> u64 {
|
||||
u64::try_from(value.as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// 看门狗状态:见过的信号 + 两个上限。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct StartupWatch {
|
||||
startup_limit: Duration,
|
||||
shutdown_limit: Duration,
|
||||
window_seen: bool,
|
||||
last_activity: Duration,
|
||||
shutdown_at: Option<Duration>,
|
||||
}
|
||||
|
||||
impl StartupWatch {
|
||||
/// 用两个上限构造(`Duration::ZERO` = 该条不判)。
|
||||
#[must_use]
|
||||
pub fn new(startup_limit: Duration, shutdown_limit: Duration) -> Self {
|
||||
Self {
|
||||
startup_limit,
|
||||
shutdown_limit,
|
||||
window_seen: false,
|
||||
last_activity: Duration::ZERO,
|
||||
shutdown_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 见到游戏窗口(之后启动预算永久失效)。
|
||||
pub fn mark_window(&mut self) {
|
||||
self.window_seen = true;
|
||||
}
|
||||
|
||||
/// 见到一行日志(进程在干活,静默计时重新开始)。
|
||||
pub fn mark_activity(&mut self, at: Duration) {
|
||||
if at > self.last_activity {
|
||||
self.last_activity = at;
|
||||
}
|
||||
}
|
||||
|
||||
/// 见到关闭标记(只记第一次出现的时刻,相对进程启动)。
|
||||
pub fn mark_shutdown(&mut self, at: Duration) {
|
||||
if self.shutdown_at.is_none() {
|
||||
self.shutdown_at = Some(at);
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否见过窗口。
|
||||
#[must_use]
|
||||
pub fn is_window_seen(&self) -> bool {
|
||||
self.window_seen
|
||||
}
|
||||
|
||||
/// 关闭标记出现的时刻(相对进程启动)。
|
||||
#[must_use]
|
||||
pub fn shutdown_at(&self) -> Option<Duration> {
|
||||
self.shutdown_at
|
||||
}
|
||||
|
||||
/// 按「进程起来多久」判一次。
|
||||
#[must_use]
|
||||
pub fn verdict(&self, elapsed: Duration) -> WatchVerdict {
|
||||
// 关闭优先:游戏自己说要退了,这时候的「没窗口」不再是信息。
|
||||
if let Some(at) = self.shutdown_at {
|
||||
let waited = elapsed.saturating_sub(at);
|
||||
if !self.shutdown_limit.is_zero() && waited > self.shutdown_limit {
|
||||
return WatchVerdict::ShutdownTimeout {
|
||||
waited,
|
||||
limit: self.shutdown_limit,
|
||||
};
|
||||
}
|
||||
return WatchVerdict::Wait;
|
||||
}
|
||||
if !self.window_seen && !self.startup_limit.is_zero() {
|
||||
// 判据是**静默**而不是「等了多久」:还在打日志就说明它还在加载。
|
||||
let quiet = elapsed.saturating_sub(self.last_activity);
|
||||
if quiet > self.startup_limit {
|
||||
return WatchVerdict::StartupTimeout {
|
||||
waited: quiet,
|
||||
limit: self.startup_limit,
|
||||
};
|
||||
}
|
||||
}
|
||||
WatchVerdict::Wait
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{StartupWatch, WatchVerdict};
|
||||
use std::time::Duration;
|
||||
|
||||
fn secs(value: u64) -> Duration {
|
||||
Duration::from_secs(value)
|
||||
}
|
||||
|
||||
fn watch() -> StartupWatch {
|
||||
StartupWatch::new(secs(180), secs(60))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waits_inside_the_startup_budget() {
|
||||
let watch = watch();
|
||||
assert_eq!(watch.verdict(secs(179)), WatchVerdict::Wait);
|
||||
assert!(matches!(
|
||||
watch.verdict(secs(181)),
|
||||
WatchVerdict::StartupTimeout { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_stays_alive_while_log_lines_keep_coming() {
|
||||
// 慢加载:一直没有窗口,但每 20s 都有一行日志 → 静默永远不越限。
|
||||
let mut watch = watch();
|
||||
for second in 1..=30u64 {
|
||||
watch.mark_activity(secs(second * 20));
|
||||
assert_eq!(
|
||||
watch.verdict(secs(second * 20 + 1)),
|
||||
WatchVerdict::Wait,
|
||||
"第 {second} 分钟还在打日志,不该判卡死"
|
||||
);
|
||||
}
|
||||
// 之后真的静默了 → 按静默时长报,而不是按总时长。
|
||||
assert_eq!(
|
||||
watch.verdict(secs(600 + 181)),
|
||||
WatchVerdict::StartupTimeout {
|
||||
waited: secs(181),
|
||||
limit: secs(180)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seen_window_disables_the_startup_budget_for_good() {
|
||||
// 大整合包慢慢加载:窗口出现过就不再判「卡在启动」,等到天荒地老也只是 Wait。
|
||||
let mut watch = watch();
|
||||
watch.mark_window();
|
||||
assert_eq!(watch.verdict(secs(1800)), WatchVerdict::Wait);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_grace_only_applies_after_the_marker() {
|
||||
let mut watch = watch();
|
||||
watch.mark_shutdown(secs(300));
|
||||
assert_eq!(watch.verdict(secs(330)), WatchVerdict::Wait);
|
||||
assert_eq!(
|
||||
watch.verdict(secs(361)),
|
||||
WatchVerdict::ShutdownTimeout {
|
||||
waited: secs(61),
|
||||
limit: secs(60)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_marker_wins_over_the_startup_budget() {
|
||||
// 没窗口但已经打了「正在关闭」:按关闭判,不按启动判(哪怕总时长已越过启动预算)。
|
||||
let mut watch = watch();
|
||||
watch.mark_shutdown(secs(175));
|
||||
assert_eq!(watch.verdict(secs(200)), WatchVerdict::Wait);
|
||||
// 宽限一到就改口:从关闭标记算起超过 60s 才是「卡在关闭」。
|
||||
assert!(matches!(
|
||||
watch.verdict(secs(236)),
|
||||
WatchVerdict::ShutdownTimeout { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_limits_disable_the_checks() {
|
||||
let mut watch = StartupWatch::new(Duration::ZERO, Duration::ZERO);
|
||||
assert_eq!(watch.verdict(secs(10_000)), WatchVerdict::Wait);
|
||||
watch.mark_shutdown(secs(1));
|
||||
assert_eq!(watch.verdict(secs(10_000)), WatchVerdict::Wait);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codes_and_descriptions_are_stable_and_name_both_numbers() {
|
||||
let verdict = WatchVerdict::StartupTimeout {
|
||||
waited: secs(300),
|
||||
limit: secs(180),
|
||||
};
|
||||
assert_eq!(verdict.code(), "startup_timeout");
|
||||
let text = verdict.describe();
|
||||
assert!(text.contains("300.0s"), "{text}");
|
||||
assert!(text.contains("180.0s"), "{text}");
|
||||
assert_eq!(
|
||||
WatchVerdict::ShutdownTimeout {
|
||||
waited: secs(90),
|
||||
limit: secs(60)
|
||||
}
|
||||
.code(),
|
||||
"shutdown_timeout"
|
||||
);
|
||||
assert!(WatchVerdict::Wait.is_wait());
|
||||
assert!(!verdict.is_wait());
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,12 @@
|
||||
//! ⚠️ 外部命令的退出码**不能**用来判断“进程是否已被结束”:温和阶段成功关掉进程后,
|
||||
//! 再发 `taskkill /F` 会得到 128(“没有这个运行实例”)。所以 [`crate::launch::process`]
|
||||
//! 的实际停止流程以“进程是否真的消失”为准(`try_wait` 轮询),本模块只提供发送动作。
|
||||
//!
|
||||
//! # 送达即登记(D10)
|
||||
//!
|
||||
//! 每次信号**真的送达**都记进 [`crate::launch::kill_registry`],退出分析据此把结局判成
|
||||
//! 「被终止」而不是「应用错误」。登记放在这一层,是因为所有结束路径都经过这里
|
||||
//! (`RunningProcess::stop` / GUI 的 `host::launch::stop` / 看门狗兜底),调用点不必各记一遍。
|
||||
|
||||
use std::io;
|
||||
|
||||
@@ -34,13 +40,25 @@ use crate::platform::child::quiet;
|
||||
/// 温和关闭(Windows 无 `/F`;POSIX `SIGTERM`)。
|
||||
pub fn terminate(pid: u32) -> io::Result<()> {
|
||||
check_pid(pid)?;
|
||||
imp::signal(pid, false)
|
||||
signal_and_note(pid, false)
|
||||
}
|
||||
|
||||
/// 强制关闭(Windows `/F`;POSIX `SIGKILL`),并尽量覆盖整棵进程树。
|
||||
pub fn force_kill(pid: u32) -> io::Result<()> {
|
||||
check_pid(pid)?;
|
||||
imp::signal(pid, true)
|
||||
signal_and_note(pid, true)
|
||||
}
|
||||
|
||||
/// 发信号,**送达后**登记「这个 pid 是我们结束的」(D10)。
|
||||
///
|
||||
/// 只在送达时登记:`taskkill` 对已退出的进程返回非 0、POSIX 的 `kill` 会报「没有送达」,
|
||||
/// 那两种情况说明它自己先走了——不能把自然退出洗成「被结束」。
|
||||
fn signal_and_note(pid: u32, force: bool) -> io::Result<()> {
|
||||
let result = imp::signal(pid, force);
|
||||
if result.is_ok() {
|
||||
crate::launch::kill_registry::note(pid);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 三段式:温和 → 宽限 → 强制。返回**强制阶段**的结果(温和阶段失败不致命)。
|
||||
|
||||
@@ -25,6 +25,26 @@ pub const HEURISTICS: &[(&str, &str)] = &[
|
||||
("sound engine started", "sound_engine"),
|
||||
];
|
||||
|
||||
/// 关闭标记的模式表(小写子串 → 名称)。
|
||||
///
|
||||
/// 游戏**自己说要退出**时会打这几行;之后进程还不退,就是卡在关闭(D6 的实机形态:
|
||||
/// 1.12.2 关窗后 `java.exe` 仍在跑)。表要保守——宁少判,不要把正常日志当关闭信号。
|
||||
pub const SHUTDOWN_MARKERS: &[(&str, &str)] = &[
|
||||
("stopping!", "stopping"),
|
||||
("stopping render thread", "render_thread"),
|
||||
("stopping worker threads", "worker_threads"),
|
||||
];
|
||||
|
||||
/// 命中关闭标记时返回启发式名称。
|
||||
#[must_use]
|
||||
pub fn shutdown_marker(line: &str) -> Option<&'static str> {
|
||||
let lower = line.to_ascii_lowercase();
|
||||
SHUTDOWN_MARKERS
|
||||
.iter()
|
||||
.find(|(needle, _)| lower.contains(needle))
|
||||
.map(|(_, name)| *name)
|
||||
}
|
||||
|
||||
/// 窗口检测器。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WindowDetector {
|
||||
@@ -84,3 +104,49 @@ impl Default for WindowDetector {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{shutdown_marker, WindowDetector};
|
||||
|
||||
#[test]
|
||||
fn window_heuristics_are_case_insensitive_and_reported_once() {
|
||||
let mut detector = WindowDetector::new();
|
||||
assert!(detector
|
||||
.observe("[12:00:00] [main/INFO]: nothing here")
|
||||
.is_none());
|
||||
let detection = detector
|
||||
.observe("[12:00:01] [main/INFO]: LWJGL Version: 3.3.1")
|
||||
.expect("应命中窗口启发式");
|
||||
assert_eq!(detection.heuristic, "lwjgl_version");
|
||||
assert!(detector.is_detected());
|
||||
assert!(
|
||||
detector.observe("LWJGL Version again").is_none(),
|
||||
"只报一次"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_markers_cover_both_log_generations() {
|
||||
// 1.12.2:`[Client thread/INFO] [minecraft/Minecraft]: Stopping!`
|
||||
assert_eq!(
|
||||
shutdown_marker("[Client thread/INFO]: Stopping!"),
|
||||
Some("stopping")
|
||||
);
|
||||
// 1.20+:渲染线程与工作线程各自打印。
|
||||
assert_eq!(
|
||||
shutdown_marker("[Render thread/INFO]: Stopping render thread"),
|
||||
Some("render_thread")
|
||||
);
|
||||
assert_eq!(
|
||||
shutdown_marker("[Worker-Main-1/INFO]: Stopping worker threads"),
|
||||
Some("worker_threads")
|
||||
);
|
||||
// 普通日志不能被当成关闭信号。
|
||||
assert_eq!(
|
||||
shutdown_marker("[main/INFO]: Started serving on 25565"),
|
||||
None
|
||||
);
|
||||
assert_eq!(shutdown_marker("Stopping server is not our business"), None);
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,41 @@ fn posix_signals_are_recognized() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_terminated_code_with_no_output_is_not_an_application_error() {
|
||||
// D10:1.12.2 挂死后被任务管理器 / `taskkill /F` 结束 —— 退出码 1,零错误输出。
|
||||
let analysis = analyze(1, 60_000, &[], false);
|
||||
assert_eq!(analysis.exit_type, ExitType::Killed);
|
||||
assert!(analysis.terminated_externally);
|
||||
assert!(
|
||||
analysis.summary().contains("疑似被外部结束"),
|
||||
"{}",
|
||||
analysis.summary()
|
||||
);
|
||||
// 同样的退出码带错误输出 → 仍是应用错误(不能把所有 1 都洗成「被结束」)。
|
||||
let lines = vec![line(
|
||||
Stream::Stderr,
|
||||
"Exception in thread \"main\" java.lang.NullPointerException",
|
||||
)];
|
||||
let analysis = analyze(1, 60_000, &lines, false);
|
||||
assert_eq!(analysis.exit_type, ExitType::ApplicationError);
|
||||
assert!(!analysis.terminated_externally);
|
||||
// POSIX 上 1 不是信号痕迹:不套这条判据(137/143 才是)。
|
||||
let analysis = analyze(1, 60_000, &[], true);
|
||||
assert_eq!(analysis.exit_type, ExitType::ApplicationError);
|
||||
assert!(!analysis.terminated_externally);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deliberate_kill_is_reported_as_killed_not_suspected() {
|
||||
// 「我们杀的」有登记作据,不该被说成「疑似」。
|
||||
let mut signals = exit_analysis::ExitSignals::new(1, 60_000, &[]);
|
||||
signals.killed = true;
|
||||
let analysis = exit_analysis::analyze(&signals);
|
||||
assert_eq!(analysis.exit_type, ExitType::Killed);
|
||||
assert!(!analysis.terminated_externally);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_ntstatus_is_explained() {
|
||||
let analysis = analyze(-1_073_741_819, 60_000, &[], false);
|
||||
|
||||
@@ -14,3 +14,4 @@ mod pump_buffer;
|
||||
mod session_events;
|
||||
mod sink_cases;
|
||||
mod tree_kill_cases;
|
||||
mod watchdog_cases;
|
||||
@@ -77,15 +77,16 @@ fn echo_script() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn long_running_script() -> String {
|
||||
fn long_running_script(dir: &std::path::Path) -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
"ping -n 30 127.0.0.1 >nul".to_string()
|
||||
}
|
||||
let (file, body) = (
|
||||
dir.join("linger.bat"),
|
||||
"@echo off\r\nping -n 30 127.0.0.1 >nul\r\n",
|
||||
);
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
"sleep 30".to_string()
|
||||
}
|
||||
let (file, body) = (dir.join("linger.sh"), "#!/bin/sh\nsleep 30\n");
|
||||
std::fs::write(&file, body).expect("写长跑脚本");
|
||||
file.display().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -191,7 +192,7 @@ fn pipeline_stop_records_killed_exit() {
|
||||
let sessions = temp_sessions_root("killed");
|
||||
let run_dir = temp_dir("killed-run");
|
||||
let launcher = launcher(&sessions, None);
|
||||
let (request, _) = shell_request(&long_running_script(), &run_dir);
|
||||
let (request, _) = shell_request(&long_running_script(&run_dir), &run_dir);
|
||||
|
||||
let mut session = launcher.spawn(&request).expect("spawn 应成功");
|
||||
assert!(session.pid() > 0);
|
||||
@@ -211,6 +212,34 @@ fn pipeline_stop_records_killed_exit() {
|
||||
assert_eq!(index[0].exit_type.as_deref(), Some("killed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kill_from_another_thread_is_recorded_as_killed() {
|
||||
// D10:GUI 的 `host::launch::stop(pid)` 是从别的线程直接 `kill_tree`,不经过
|
||||
// `LaunchSession::stop()`。这条路以前会记成 `application_error`(Windows 退出码 1)。
|
||||
let sessions = temp_sessions_root("external-kill");
|
||||
let run_dir = temp_dir("external-kill-run");
|
||||
let launcher = launcher(&sessions, None);
|
||||
let (request, _) = shell_request(&long_running_script(&run_dir), &run_dir);
|
||||
|
||||
let session = launcher.spawn(&request).expect("spawn 应成功");
|
||||
let pid = session.pid();
|
||||
// `kill_tree` 的返回值不代表「进程还在」:温和阶段可能已经关掉了它(taskkill 报 128)。
|
||||
let _ = unlucky_core::launch::tree_kill::kill_tree(pid, Duration::from_millis(200));
|
||||
let handle = session.finish().expect("收尾应成功");
|
||||
|
||||
assert_eq!(
|
||||
handle.exit.exit_type,
|
||||
ExitType::Killed,
|
||||
"我们杀的要判成 killed:{}",
|
||||
handle.exit.summary()
|
||||
);
|
||||
assert!(
|
||||
!handle.exit.terminated_externally,
|
||||
"有登记作据就不该只是「疑似」:{}",
|
||||
handle.exit.summary()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_assembly_failure_still_writes_evidence() {
|
||||
let sessions = temp_sessions_root("assembly-fail");
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
//! D6 看门狗:有界等待(真进程,不是 mock)。
|
||||
//!
|
||||
//! 四条用例各自钉一个判据:
|
||||
//!
|
||||
//! 1. 没窗口也没退出 → 立刻停止等待并报错,**进程还活着**(不杀);
|
||||
//! 2. 窗口出现过 → 慢慢加载不会被判死,正常退出照常给结果;
|
||||
//! 3. 出现关闭标记后仍不退出 → 判「卡在关闭」(实机 D6 的形态);
|
||||
//! 4. 短命的正常退出 → 不会被超时误判成错误。
|
||||
//!
|
||||
//! 子进程一律是**脚本文件**(`.bat` / `.sh`),不把 `&` / 重定向塞进 `cmd /C` 的单个参数:
|
||||
//! 那样 cmd 的引号剥离规则会让脚本变成另一条命令(第一版就是这么翻车的)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
use unlucky_core::launch::tree_kill;
|
||||
use unlucky_core::launch::watchdog::WatchdogConfig;
|
||||
|
||||
fn launcher(sessions_root: &Path, startup: Duration, grace: Duration) -> Launcher {
|
||||
let mut config = LauncherConfig::new(sessions_root);
|
||||
config.platform = PlatformCtx::new(OsFamily::Windows, "x86_64");
|
||||
config.process = ProcessConfig {
|
||||
graceful_stop: Duration::from_millis(200),
|
||||
// 泵的预算压到 200ms:放弃等待之后不该再为「等日志落盘」多等默认的 10 秒。
|
||||
watchdog: WatchdogConfig {
|
||||
pump_timeout: Duration::from_millis(200),
|
||||
},
|
||||
startup_timeout: startup,
|
||||
shutdown_grace: grace,
|
||||
..ProcessConfig::default()
|
||||
};
|
||||
Launcher::new(config)
|
||||
}
|
||||
|
||||
/// 写一个伪装成 java 的脚本,返回它的路径。
|
||||
fn script(dir: &Path, name: &str, lines: &[&str]) -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
let (file, shebang) = (dir.join(format!("{name}.bat")), "@echo off");
|
||||
#[cfg(not(windows))]
|
||||
let (file, shebang) = (dir.join(format!("{name}.sh")), "#!/bin/sh");
|
||||
let body = format!("{shebang}\n{}\n", lines.join("\n"));
|
||||
std::fs::write(&file, body).expect("写测试脚本");
|
||||
file
|
||||
}
|
||||
|
||||
/// 用脚本文件当 java(命令行组装、事件流、退出判定都是真的,只有 JVM 是假的)。
|
||||
fn request(script_path: &Path, run_dir: &Path) -> LaunchRequest {
|
||||
let (mut request, _) = legacy_forge_request();
|
||||
#[cfg(windows)]
|
||||
{
|
||||
request.wrapper = vec![
|
||||
"cmd".to_string(),
|
||||
"/C".to_string(),
|
||||
script_path.display().to_string(),
|
||||
];
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
request.wrapper = vec!["/bin/sh".to_string(), script_path.display().to_string()];
|
||||
}
|
||||
request.java = JavaSelection::new("fake-java", 17, 64);
|
||||
request.manifest.legacy_arguments = None;
|
||||
request.dirs.game_dir = run_dir.to_path_buf();
|
||||
request.dirs.natives_dir = run_dir.join("natives");
|
||||
request.user.jvm = Vec::new();
|
||||
request.user.game = Vec::new();
|
||||
request
|
||||
}
|
||||
|
||||
/// 进程是否还活着(看门狗「不杀进程」的判据)。
|
||||
fn is_alive(pid: u32) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let output = std::process::Command::new("tasklist")
|
||||
.args(["/FI", &format!("PID eq {pid}"), "/NH"])
|
||||
.output()
|
||||
.expect("tasklist 应可执行");
|
||||
String::from_utf8_lossy(&output.stdout).contains(&pid.to_string())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
std::process::Command::new("kill")
|
||||
.args(["-0", &pid.to_string()])
|
||||
.status()
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条「活着但什么也不干」的脚本(约 3 秒)。
|
||||
fn linger_lines() -> Vec<&'static str> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
vec!["ping -n 4 127.0.0.1 >nul"]
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
vec!["sleep 3"]
|
||||
}
|
||||
}
|
||||
|
||||
/// 先打一行窗口标记,再活 1 秒左右。
|
||||
fn window_then_exit_lines() -> Vec<&'static str> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
vec!["echo LWJGL Version: 3.3.1", "ping -n 2 127.0.0.1 >nul"]
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
vec!["echo 'LWJGL Version: 3.3.1'", "sleep 1"]
|
||||
}
|
||||
}
|
||||
|
||||
/// 每秒打一行、总共约 4 秒的脚本(**有**日志、**没有**窗口标记)。
|
||||
#[cfg(windows)]
|
||||
fn chatty_lines() -> Vec<&'static str> {
|
||||
// `ping` 每秒打一行;不重定向就是为了让它真的输出。
|
||||
vec!["ping -n 5 127.0.0.1"]
|
||||
}
|
||||
|
||||
/// 同上(POSIX)。
|
||||
#[cfg(not(windows))]
|
||||
fn chatty_lines() -> Vec<&'static str> {
|
||||
vec!["i=0; while [ $i -lt 5 ]; do echo \"tick $i\"; sleep 1; i=$((i+1)); done"]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chatty_slow_load_is_not_mistaken_for_a_hang() {
|
||||
// 一直没有窗口,但每秒都有一行日志:静默预算 1.5s,总时长 4s+ ⇒ 不能被判死。
|
||||
let sessions = temp_sessions_root("watchdog-chatty");
|
||||
let run_dir = temp_dir("watchdog-chatty-run");
|
||||
let path = script(&run_dir, "chatty", &chatty_lines());
|
||||
let launcher = launcher(
|
||||
&sessions,
|
||||
Duration::from_millis(1500),
|
||||
Duration::from_secs(60),
|
||||
);
|
||||
let session = launcher
|
||||
.spawn(&request(&path, &run_dir))
|
||||
.expect("spawn 应成功");
|
||||
let started = Instant::now();
|
||||
let handle = session.finish().expect("还在打日志就不算卡死");
|
||||
assert!(
|
||||
started.elapsed() > Duration::from_millis(1500),
|
||||
"这条用例要真的越过静默预算,否则什么都没验到:{:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
assert_eq!(handle.exit.code, 0);
|
||||
assert_eq!(handle.exit.exit_type, ExitType::Normal);
|
||||
assert!(
|
||||
!handle
|
||||
.index
|
||||
.as_ref()
|
||||
.is_some_and(|entry| entry.window_detected),
|
||||
"这条脚本不该命中窗口启发式"
|
||||
);
|
||||
}
|
||||
|
||||
/// 先打关闭标记,再挂住不走(约 9 秒)。
|
||||
fn shutdown_then_hang_lines() -> Vec<&'static str> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
vec![
|
||||
"echo [Client thread/INFO]: Stopping!",
|
||||
"ping -n 10 127.0.0.1 >nul",
|
||||
]
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
vec!["echo '[Client thread/INFO]: Stopping!'", "sleep 9"]
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_timeout_stops_waiting_and_never_kills_the_process() {
|
||||
let sessions = temp_sessions_root("watchdog-startup");
|
||||
let run_dir = temp_dir("watchdog-startup-run");
|
||||
let path = script(&run_dir, "linger", &linger_lines());
|
||||
let launcher = launcher(
|
||||
&sessions,
|
||||
Duration::from_millis(300),
|
||||
Duration::from_secs(60),
|
||||
);
|
||||
let session = launcher
|
||||
.spawn(&request(&path, &run_dir))
|
||||
.expect("spawn 应成功");
|
||||
let pid = session.pid();
|
||||
let log_path = session.log_path().to_path_buf();
|
||||
let started = Instant::now();
|
||||
let error = session
|
||||
.finish()
|
||||
.expect_err("没窗口也没退出:必须停止等待并报错,不能无限等");
|
||||
let spent = started.elapsed();
|
||||
assert!(
|
||||
spent < Duration::from_secs(5),
|
||||
"超时后应立即返回:{spent:?}"
|
||||
);
|
||||
match &error {
|
||||
LaunchError::Watchdog {
|
||||
pid: reported,
|
||||
reason,
|
||||
limit_ms,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*reported, pid);
|
||||
assert_eq!(*reason, "startup_timeout");
|
||||
assert_eq!(*limit_ms, 300, "上限要如实回报(可配 + 可见)");
|
||||
}
|
||||
other => panic!("应报看门狗超时,实际 {other:?}"),
|
||||
}
|
||||
let text = error.to_string();
|
||||
assert!(text.contains("没有被结束"), "{text}");
|
||||
assert!(text.contains("ULL_LAUNCH_STARTUP_TIMEOUT_SECS"), "{text}");
|
||||
|
||||
// 会话照样收尾(历史里看得见这一局),原因是 still_running 而不是 exit。
|
||||
let events = read_events(&log_path);
|
||||
let last = events.last().expect("至少写了 session_start");
|
||||
assert_eq!(last["kind"], "session_end");
|
||||
assert_eq!(last["payload"]["reason"], "still_running");
|
||||
|
||||
// 关键断言:进程还活着 —— 看门狗只停止等待,绝不结束进程。
|
||||
assert!(is_alive(pid), "看门狗不该结束进程(pid {pid} 已经不在了)");
|
||||
let _ = tree_kill::force_kill(pid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seen_window_keeps_the_wait_alive_for_slow_loads() {
|
||||
// 启动预算是 300ms,但窗口标记在几十毫秒内就到了:慢加载不是卡死。
|
||||
let sessions = temp_sessions_root("watchdog-window");
|
||||
let run_dir = temp_dir("watchdog-window-run");
|
||||
let path = script(&run_dir, "windowed", &window_then_exit_lines());
|
||||
let launcher = launcher(
|
||||
&sessions,
|
||||
Duration::from_millis(300),
|
||||
Duration::from_secs(60),
|
||||
);
|
||||
let session = launcher
|
||||
.spawn(&request(&path, &run_dir))
|
||||
.expect("spawn 应成功");
|
||||
let started = Instant::now();
|
||||
let handle = session
|
||||
.finish()
|
||||
.expect("窗口出现过之后就该一直等到它自己退出");
|
||||
assert!(
|
||||
started.elapsed() > Duration::from_millis(300),
|
||||
"这条用例要真的越过启动预算,否则什么都没验到"
|
||||
);
|
||||
assert_eq!(
|
||||
handle.exit.exit_type,
|
||||
ExitType::Normal,
|
||||
"{}",
|
||||
handle.summary()
|
||||
);
|
||||
assert_eq!(handle.exit.code, 0);
|
||||
assert!(
|
||||
handle
|
||||
.index
|
||||
.as_ref()
|
||||
.is_some_and(|entry| entry.window_detected),
|
||||
"窗口检测要落进索引(GUI/诊断都看这一位)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shutdown_marker_followed_by_hang_is_reported_as_stuck_on_close() {
|
||||
// 实机 D6:关窗后 java.exe 仍存活。游戏自己打了「Stopping!」,之后就再没动静。
|
||||
let sessions = temp_sessions_root("watchdog-shutdown");
|
||||
let run_dir = temp_dir("watchdog-shutdown-run");
|
||||
let path = script(&run_dir, "hanging", &shutdown_then_hang_lines());
|
||||
let launcher = launcher(
|
||||
&sessions,
|
||||
Duration::from_secs(60),
|
||||
Duration::from_millis(400),
|
||||
);
|
||||
let session = launcher
|
||||
.spawn(&request(&path, &run_dir))
|
||||
.expect("spawn 应成功");
|
||||
let pid = session.pid();
|
||||
let started = Instant::now();
|
||||
let error = session
|
||||
.finish()
|
||||
.expect_err("关闭标记之后挂住:必须停止等待并报错");
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(6),
|
||||
"宽限一到就要返回:{:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
match &error {
|
||||
LaunchError::Watchdog { reason, .. } => assert_eq!(*reason, "shutdown_timeout"),
|
||||
other => panic!("应报卡在关闭,实际 {other:?}"),
|
||||
}
|
||||
assert!(error.to_string().contains("已开始关闭"), "{error}");
|
||||
let _ = tree_kill::force_kill(pid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quick_clean_exit_is_never_punished_by_the_watchdog() {
|
||||
let sessions = temp_sessions_root("watchdog-quick");
|
||||
let run_dir = temp_dir("watchdog-quick-run");
|
||||
let path = script(&run_dir, "quick", &["echo hello"]);
|
||||
let launcher = launcher(&sessions, Duration::from_secs(3), Duration::from_secs(3));
|
||||
let session = launcher
|
||||
.spawn(&request(&path, &run_dir))
|
||||
.expect("spawn 应成功");
|
||||
let handle = session.finish().expect("正常退出不该报错");
|
||||
assert_eq!(handle.exit.code, 0);
|
||||
assert_eq!(handle.exit.exit_type, ExitType::Normal);
|
||||
assert!(!handle.exit.terminated_externally);
|
||||
}
|
||||
Reference in new issue
Block a user