Files
Unlucky-Launcher/crates/core/src/launch/process.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

318 lines
11 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.
//! 进程管理:spawn + stdout/stderr 泵 + 进程树回收。
//!
//! 对应 `analyse/02` §6 的四条线程模型(`stdout-pump` / `stderr-pump` / `exit-waiter`),
//! 但线程生命周期的收尾由 [`crate::launch::watchdog`] 兜底,不无条件 `join`。
//!
//! 本模块只管“把命令行跑起来并收集输出”,不做组件检查、不做诊断——
//! 那些是 `launch` 上层流水线(阶段B)与 M15 的职责。
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::launch::args_table::AssembledCommand;
use crate::launch::error::{LaunchError, Result};
use crate::launch::pump::{LineSink, LogLine, PumpGroup, RingBuffer, RingBufferConfig};
use crate::launch::tree_kill;
use crate::launch::watchdog::{self, PumpVerdict, WatchdogConfig};
/// 强制结束后等进程消失的预算。
const FORCE_KILL_BUDGET: Duration = Duration::from_secs(5);
/// 轮询进程状态的间隔(有界等待也用这个)。
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)]
pub struct ProcessConfig {
/// 日志泵等待预算。
pub watchdog: WatchdogConfig,
/// 停止时温和关闭的宽限期。
pub graceful_stop: Duration,
/// 环形缓冲配置。
pub buffer: RingBufferConfig,
/// 未见游戏窗口的**静默**预算(D6;`0` = 不判)。
///
/// 判据是「连续这么久没有任何日志输出、也没见到窗口」——还在打日志就不算卡死,
/// 大整合包慢慢加载不会被判进来。
pub startup_timeout: Duration,
/// 出现关闭标记后仍不退出的宽限(D6;`0` = 不判)。
pub shutdown_grace: Duration,
}
impl Default for ProcessConfig {
fn default() -> Self {
Self {
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 {
child: Child,
pump: PumpGroup,
buffer: Arc<Mutex<RingBuffer>>,
started_at: Instant,
pid: u32,
config: ProcessConfig,
}
impl RunningProcess {
/// 启动进程并接管两条输出流。
pub fn spawn(
command: &AssembledCommand,
working_dir: Option<&Path>,
sink: Arc<dyn LineSink>,
config: ProcessConfig,
) -> Result<Self> {
let mut process = Command::new(&command.program);
process.args(&command.args);
for (key, value) in &command.env {
process.env(key, value);
}
if let Some(dir) = working_dir {
process.current_dir(dir);
}
process
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
/// 独立进程组:便于 `taskkill /T` 递归回收。
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
process.creation_flags(CREATE_NEW_PROCESS_GROUP);
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
// 与 Windows 的 CREATE_NEW_PROCESS_GROUP 对称:子进程自成一个进程组,
// `tree_kill` 的 `kill -- -<pgid>` 才能覆盖整棵子树。`process_group` 是
// std 1.64+ 的**安全** API,不需要 pre_exec / unsafe(旧注释说“unsafe 被挡”已过时)。
// 参数 0 = `pgid = 子进程 pid`。
process.process_group(0);
}
let mut child = process.spawn().map_err(|source| LaunchError::Spawn {
program: command.program.clone(),
source,
})?;
let pid = child.id();
let buffer = Arc::new(Mutex::new(RingBuffer::new(config.buffer)));
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let pump = PumpGroup::spawn(stdout, stderr, buffer.clone(), sink);
Ok(Self {
child,
pump,
buffer,
started_at: Instant::now(),
pid,
config,
})
}
/// 进程号。
pub fn pid(&self) -> u32 {
self.pid
}
/// 已运行毫秒数。
pub fn uptime_ms(&self) -> u64 {
u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX)
}
/// 启动时刻(窗口检测计时用)。
pub fn started_at(&self) -> Instant {
self.started_at
}
/// 共享的日志环形缓冲。
pub fn buffer(&self) -> Arc<Mutex<RingBuffer>> {
self.buffer.clone()
}
/// 当前可读日志快照。
pub fn lines(&self) -> Vec<LogLine> {
match self.buffer.lock() {
Ok(guard) => guard.lines().cloned().collect(),
Err(_) => Vec::new(),
}
}
/// 等待进程退出(不等待日志泵)。
pub fn wait(&mut self) -> Result<ExitStatus> {
self.child.wait().map_err(|source| LaunchError::Io {
op: "wait_child",
path: PathBuf::from(self.pid.to_string()),
source,
})
}
/// 非阻塞取退出状态(`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)
}
/// 进程是否仍在运行。
pub fn is_running(&mut self) -> bool {
matches!(self.child.try_wait(), Ok(None))
}
/// 三段式停止整棵进程树(温和 → 宽限 → 强制)。
///
/// 判定标准是**进程是否真的没了**,不是 `taskkill`/`kill` 的退出码:
/// 外部命令对“已经退出的进程”会返回非 0(Windows 上 `taskkill` 报 128),
/// 把它当失败会误报——尤其是温和阶段已经把进程关掉的情况。
pub fn stop(&mut self) -> Result<()> {
if !self.is_running() {
return Ok(());
}
let _ = tree_kill::terminate(self.pid);
if self.wait_until_exit(self.config.graceful_stop) {
return Ok(());
}
let _ = tree_kill::force_kill(self.pid);
if self.wait_until_exit(FORCE_KILL_BUDGET) {
return Ok(());
}
Err(LaunchError::Kill {
pid: self.pid,
source: std::io::Error::other("强制结束后进程仍在运行"),
})
}
/// 直接强杀(看门狗/崩溃兜底路径)。
pub fn force_kill(&mut self) -> Result<()> {
if !self.is_running() {
return Ok(());
}
let _ = tree_kill::force_kill(self.pid);
if self.wait_until_exit(FORCE_KILL_BUDGET) {
Ok(())
} else {
Err(LaunchError::Kill {
pid: self.pid,
source: std::io::Error::other("强制结束后进程仍在运行"),
})
}
}
fn wait_until_exit(&mut self, budget: Duration) -> bool {
let deadline = Instant::now() + budget;
loop {
match self.child.try_wait() {
Ok(Some(_)) => return true,
Ok(None) => {}
Err(_) => return false,
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(POLL_INTERVAL);
}
}
}
/// 从退出状态取整数退出码。
///
/// POSIX 上被子进程信号杀死没有 `code()`,按 shell 惯例补 `128 + signal`
/// (`SIGKILL` → 137、`SIGTERM` → 143,与 `analyse/02` 附录 C 一致)。
pub fn exit_code(status: &ExitStatus) -> i32 {
if let Some(code) = status.code() {
return code;
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(signal) = status.signal() {
return 128 + signal;
}
}
-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);
}
}