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

+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);
}
}