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:
AskaEth committed 2026-11-09 17:07:22 +08:00
1 parent 5959a27add
commit ff78fe2cae
23 files changed
+1708 -250

No files matched your search

+25 -15
View File
@@ -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)
}
+9 -103
View File
@@ -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"
);
}
}
+167
View File
@@ -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"));
}
}
+8 -4
View File
@@ -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};
+142 -10
View File
@@ -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));
}
}
+36
View File
@@ -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,
}
}
}
+47
View File
@@ -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 {
+108
View File
@@ -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), "上限之后的条目仍在表里");
}
}
+96 -100
View File
@@ -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
}
}
+23
View File
@@ -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()
}
+13
View File
@@ -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};
+15
View File
@@ -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(),
})
}
}
/// 解压失败原因。
+88 -2
View File
@@ -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);
}
}
+3
View File
@@ -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",
}
}
}
+130
View File
@@ -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
}
}
+31 -6
View File
@@ -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);
}
+297
View File
@@ -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());
}
}
+20 -2
View File
@@ -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
}
/// 三段式:温和 → 宽限 → 强制。返回**强制阶段**的结果(温和阶段失败不致命)。
+66
View File
@@ -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);
}
}
+35
View File
@@ -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);
+1
View File
@@ -14,3 +14,4 @@ mod pump_buffer;
mod session_events;
mod sink_cases;
mod tree_kill_cases;
mod watchdog_cases;
+37 -8
View File
@@ -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");
+311
View File
@@ -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);
}