Files
Unlucky-Launcher/crates/core/src/launch/startup_watch.rs
T
AskaEth ff78fe2cae 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。
2026-11-09 17:07:22 +08:00

298 lines
9.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 启动看门狗:把「还在慢慢加载」与「活着但没动静」分开(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());
}
}