diff --git a/crates/cli/src/commands/launch.rs b/crates/cli/src/commands/launch.rs index 00ff754..8d94ea8 100644 --- a/crates/cli/src/commands/launch.rs +++ b/crates/cli/src/commands/launch.rs @@ -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 { ..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 { // M188/M211:命令行这一路也把启动器日志写进 `/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 { ); 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 { 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) } diff --git a/crates/core/src/game/java/mapping.rs b/crates/core/src/game/java/mapping.rs index 930b889..bd8b404 100644 --- a/crates/core/src/game/java/mapping.rs +++ b/crates/core/src/game/java/mapping.rs @@ -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 { - 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" - ); - } } diff --git a/crates/core/src/game/java/mismatch.rs b/crates/core/src/game/java/mismatch.rs new file mode 100644 index 0000000..a2168d8 --- /dev/null +++ b/crates/core/src/game/java/mismatch.rs @@ -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 { + 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")); + } +} diff --git a/crates/core/src/game/java/mod.rs b/crates/core/src/game/java/mod.rs index 046128a..2e8c105 100644 --- a/crates/core/src/game/java/mod.rs +++ b/crates/core/src/game/java/mod.rs @@ -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}; diff --git a/crates/core/src/game/java/select.rs b/crates/core/src/game/java/select.rs index 496a0fd..a9d5bb8 100644 --- a/crates/core/src/game/java/select.rs +++ b/crates/core/src/game/java/select.rs @@ -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 { + match self { + Self::Proceed => None, + Self::Block(mismatch) => Some(mismatch), + } + } +} + +/// 不满足要求时是「只告警」还是「拦住」(D4 的取舍,理由见 [`LaunchVerdict`])。 +/// +/// 判据只有两条:不符的方向不重要(两个方向都必然起不来),**是不是用户自己选的**才重要。 +#[must_use] +pub fn decide(mismatch: Option, 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)); + } } diff --git a/crates/core/src/launch/error.rs b/crates/core/src/launch/error.rs index 1d5d435..7e560f4 100644 --- a/crates/core/src/launch/error.rs +++ b/crates/core/src/launch/error.rs @@ -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, } } } diff --git a/crates/core/src/launch/exit_analysis.rs b/crates/core/src/launch/exit_analysis.rs index a4bc842..6a21c22 100644 --- a/crates/core/src/launch/exit_analysis.rs +++ b/crates/core/src/launch/exit_analysis.rs @@ -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, /// Windows NTSTATUS 解释(例 `0xC0000005 ACCESS_VIOLATION`)。 pub windows_status: Option, + /// 是否**疑似被外部结束**(任务管理器 / 强杀),而不是应用自己崩了(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 { let (hex, name) = match code { diff --git a/crates/core/src/launch/kill_registry.rs b/crates/core/src/launch/kill_registry.rs new file mode 100644 index 0000000..e391393 --- /dev/null +++ b/crates/core/src/launch/kill_registry.rs @@ -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> { + static REGISTRY: OnceLock>> = 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), "上限之后的条目仍在表里"); + } +} diff --git a/crates/core/src/launch/launch_session.rs b/crates/core/src/launch/launch_session.rs index 7a1feeb..1bb5623 100644 --- a/crates/core/src/launch/launch_session.rs +++ b/crates/core/src/launch/launch_session.rs @@ -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 { - 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 { + 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 { + 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_argv: Vec, - /// 退出判定。 - pub exit: ExitAnalysis, - /// 诊断结论(M15 产出,已写入事件流)。 - pub diagnostics: Option, - /// 索引条目(写入 `index.jsonl` 的那一行)。 - pub index: Option, - /// 崩溃现场文件。 - pub artifacts: Vec, - /// 游戏存活毫秒数。 - pub uptime_ms: u64, - /// 日志泵是否超时(watchdog 触发 = 有子进程没关 stdout)。 - pub pump_stalled: bool, - /// 会话写入错误(磁盘问题;不影响进程结果)。 - pub event_write_errors: Vec, -} - -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::>() - .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 - } -} diff --git a/crates/core/src/launch/launcher.rs b/crates/core/src/launch/launcher.rs index ea023b6..e1d3089 100644 --- a/crates/core/src/launch/launcher.rs +++ b/crates/core/src/launch/launcher.rs @@ -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 { self.spawn(request)?.finish() } diff --git a/crates/core/src/launch/mod.rs b/crates/core/src/launch/mod.rs index b296ae3..af63e1f 100644 --- a/crates/core/src/launch/mod.rs +++ b/crates/core/src/launch/mod.rs @@ -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}; diff --git a/crates/core/src/launch/natives/mod.rs b/crates/core/src/launch/natives/mod.rs index a57e83c..b1fe040 100644 --- a/crates/core/src/launch/natives/mod.rs +++ b/crates/core/src/launch/natives/mod.rs @@ -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(), + }) + } } /// 解压失败原因。 diff --git a/crates/core/src/launch/process.rs b/crates/core/src/launch/process.rs index 79a9582..29f3f1e 100644 --- a/crates/core/src/launch/process.rs +++ b/crates/core/src/launch/process.rs @@ -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 { + parse_seconds(&std::env::var(name).ok()?) +} + +/// 解析秒数(`"120"` → 120s,`"0"` → 0s = 关闭该判据;其余一律 `None`)。 +fn parse_seconds(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + trimmed.parse::().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> { + 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); + } +} diff --git a/crates/core/src/launch/session.rs b/crates/core/src/launch/session.rs index a1dd048..dfdec89 100644 --- a/crates/core/src/launch/session.rs +++ b/crates/core/src/launch/session.rs @@ -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", } } } diff --git a/crates/core/src/launch/session_handle.rs b/crates/core/src/launch/session_handle.rs new file mode 100644 index 0000000..04b32c5 --- /dev/null +++ b/crates/core/src/launch/session_handle.rs @@ -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_argv: Vec, + /// 退出判定。 + pub exit: ExitAnalysis, + /// 诊断结论(M15 产出,已写入事件流)。 + pub diagnostics: Option, + /// 索引条目(写入 `index.jsonl` 的那一行)。 + pub index: Option, + /// 崩溃现场文件。 + pub artifacts: Vec, + /// 游戏存活毫秒数。 + pub uptime_ms: u64, + /// 日志泵是否超时(watchdog 触发 = 有子进程没关 stdout)。 + pub pump_stalled: bool, + /// 会话写入错误(磁盘问题;不影响进程结果)。 + pub event_write_errors: Vec, +} + +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, + exit: ExitAnalysis, + diagnostics: Option, + index: Option, + artifacts: Vec, + uptime_ms: u64, + pump_stalled: bool, + event_write_errors: Vec, + ) -> 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::>() + .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 + } +} diff --git a/crates/core/src/launch/session_sink.rs b/crates/core/src/launch/session_sink.rs index 712b28e..f570fc5 100644 --- a/crates/core/src/launch/session_sink.rs +++ b/crates/core/src/launch/session_sink.rs @@ -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, detector: Mutex, + shutdown_at: Mutex>, + last_line_at: Mutex>, } 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 { + self.shutdown_at.lock().ok().and_then(|guard| *guard) + } + + /// 最后一行的时刻(D6:还在打日志就说明还在加载,不算卡死)。 + pub fn last_line_at(&self) -> Option { + 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); } diff --git a/crates/core/src/launch/startup_watch.rs b/crates/core/src/launch/startup_watch.rs new file mode 100644 index 0000000..1ff5539 --- /dev/null +++ b/crates/core/src/launch/startup_watch.rs @@ -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, +} + +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 { + 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()); + } +} diff --git a/crates/core/src/launch/tree_kill.rs b/crates/core/src/launch/tree_kill.rs index 7fc1a1b..4aee9b0 100644 --- a/crates/core/src/launch/tree_kill.rs +++ b/crates/core/src/launch/tree_kill.rs @@ -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 } /// 三段式:温和 → 宽限 → 强制。返回**强制阶段**的结果(温和阶段失败不致命)。 diff --git a/crates/core/src/launch/window_detect.rs b/crates/core/src/launch/window_detect.rs index 1a1a667..fa6ce2d 100644 --- a/crates/core/src/launch/window_detect.rs +++ b/crates/core/src/launch/window_detect.rs @@ -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); + } +} diff --git a/crates/core/tests/launch/exit_cases.rs b/crates/core/tests/launch/exit_cases.rs index c861b6b..7a4805a 100644 --- a/crates/core/tests/launch/exit_cases.rs +++ b/crates/core/tests/launch/exit_cases.rs @@ -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); diff --git a/crates/core/tests/launch/main.rs b/crates/core/tests/launch/main.rs index f6d53ac..bdb2d2b 100644 --- a/crates/core/tests/launch/main.rs +++ b/crates/core/tests/launch/main.rs @@ -14,3 +14,4 @@ mod pump_buffer; mod session_events; mod sink_cases; mod tree_kill_cases; +mod watchdog_cases; diff --git a/crates/core/tests/launch/pipeline_cases.rs b/crates/core/tests/launch/pipeline_cases.rs index b14020f..23665f3 100644 --- a/crates/core/tests/launch/pipeline_cases.rs +++ b/crates/core/tests/launch/pipeline_cases.rs @@ -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"); diff --git a/crates/core/tests/launch/watchdog_cases.rs b/crates/core/tests/launch/watchdog_cases.rs new file mode 100644 index 0000000..dd2c87b --- /dev/null +++ b/crates/core/tests/launch/watchdog_cases.rs @@ -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); +}