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

211 lines
8.5 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.
//! 进程树回收:`SIGTERM → 宽限 → SIGKILL` 三段式关闭。
//!
//! HMCL 只 `process.destroy()` 单进程(`analyse/02` §6.8),子进程会残留、日志尾部丢失。
//! 这里做进程**树**回收:
//!
//! - Windows:`taskkill /T /F /PID`(`/T` 递归子树)。P0 不引入 `windows-sys`,因此没有
//! Job Object——Job Object(`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`)留 P1,替换点就在本文件
//! (tower 已批准,见 `launch-接口约定.md` §7-1)。
//! - POSIX:子进程由 [`crate::launch::process`] 用 `CommandExt::process_group(0)` 放进**自己的**
//! 进程组(与 Windows 的 `CREATE_NEW_PROCESS_GROUP` 对称,std 1.64+ 的安全 API),因此
//! `kill -TERM -- -<pgid>` 一次覆盖整棵子树 → 宽限 → `kill -KILL -- -<pgid>`,再补一发单 pid。
//!
//! 两个平台都先尝试“温和”,宽限期后再“强制”。
//!
//! # 两条来自 CI 的硬约束
//!
//! 1. **组杀必须先验组**([`group_kill_target`]):目标进程必须是它自己进程组的**组长**,
//! 且该组不能是我们所在的组。否则一次 `kill -- -<pgid>` 会把测试进程(CI 容器里全体进程
//! 常常共用 pgid 1)一起带走——M48 之前 `test (ubuntu-latest)` 就是这么被 SIGTERM 打断的。
//! 验不过就只发单 pid。
//! 2. **`--` 不能省**:procps-ng 的 `kill` 会把裸 `-<pgid>` 当成又一个信号选项吞掉,**退出码 0
//! 却什么都没发**(procps-ng 4.0.2 实测)。旧实现把这种“假成功”当组杀成功直接返回,
//! 于是单 pid 兜底永远不执行,`stop()` 只能干等超时——`stop_kills_process_tree` 与
//! `pipeline_stop_records_killed_exit` 两条红灯的真正死因。
//!
//! ⚠️ 外部命令的退出码**不能**用来判断“进程是否已被结束”:温和阶段成功关掉进程后,
//! 再发 `taskkill /F` 会得到 128(“没有这个运行实例”)。所以 [`crate::launch::process`]
//! 的实际停止流程以“进程是否真的消失”为准(`try_wait` 轮询),本模块只提供发送动作。
//!
//! # 送达即登记(D10)
//!
//! 每次信号**真的送达**都记进 [`crate::launch::kill_registry`],退出分析据此把结局判成
//! 「被终止」而不是「应用错误」。登记放在这一层,是因为所有结束路径都经过这里
//! (`RunningProcess::stop` / GUI 的 `host::launch::stop` / 看门狗兜底),调用点不必各记一遍。
use std::io;
use crate::platform::child::quiet;
/// 温和关闭(Windows 无 `/F`;POSIX `SIGTERM`)。
pub fn terminate(pid: u32) -> io::Result<()> {
check_pid(pid)?;
signal_and_note(pid, false)
}
/// 强制关闭(Windows `/F`;POSIX `SIGKILL`),并尽量覆盖整棵进程树。
pub fn force_kill(pid: u32) -> io::Result<()> {
check_pid(pid)?;
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
}
/// 三段式:温和 → 宽限 → 强制。返回**强制阶段**的结果(温和阶段失败不致命)。
///
/// 注意:返回值不代表“进程已经死了”(见模块注释)。需要准确判定请用
/// [`crate::launch::process::RunningProcess::stop`]。
pub fn kill_tree(pid: u32, grace: std::time::Duration) -> io::Result<()> {
check_pid(pid)?;
let _ = terminate(pid);
if !grace.is_zero() {
std::thread::sleep(grace);
}
force_kill(pid)
}
/// `pid` 0 意味着“当前进程组”(POSIX `kill` 的 `-0`)——绝不能顺手发出去。
fn check_pid(pid: u32) -> io::Result<()> {
if pid == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"无效的 pid 0:拒绝向当前进程组发信号",
));
}
Ok(())
}
/// 允许组杀的进程组号;`None` = 只能发单 pid。
///
/// 三重校验,任一不过就退回单 pid:
/// 1. 目标必须是它自己进程组的组长(`pgid == pid`)——否则 `-<pgid>` 指的不是“它的”子树;
/// 2. 必须能读到我们自己所在的进程组;
/// 3. 目标组不能就是我们所在的组——否则信号会打到自己人身上。
#[cfg(unix)]
pub fn group_kill_target(pid: u32) -> Option<u32> {
if pid == 0 {
return None;
}
let target = pgid_of(pid)?;
if target != pid {
return None;
}
let own = pgid_of(std::process::id())?;
(target != own).then_some(target)
}
/// 读 `pid` 所在进程组号。Linux 走 `/proc`,其它 POSIX 用 `ps`;读不到就是 `None`(不发组杀)。
#[cfg(target_os = "linux")]
fn pgid_of(pid: u32) -> Option<u32> {
// `/proc/<pid>/stat`: `pid (comm) state ppid pgrp ...`,`comm` 可能含空格和括号,
// 所以从**最后一个** `)` 之后开始数字段。
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let tail = &stat[stat.rfind(')')? + 1..];
let mut fields = tail.split_whitespace();
fields.next()?; // state
fields.next()?; // ppid
fields.next()?.parse().ok() // pgrp
}
#[cfg(all(unix, not(target_os = "linux")))]
fn pgid_of(pid: u32) -> Option<u32> {
let output = quiet("ps")
.args(["-o", "pgid=", "-p", &pid.to_string()])
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
}
#[cfg(windows)]
mod imp {
use super::*;
pub(super) fn signal(pid: u32, force: bool) -> io::Result<()> {
// `CREATE_NO_WINDOW` 由 `platform::child::quiet` 统一带(否则 kill 子进程会闪一个黑框)。
let mut command = quiet("taskkill");
command.arg("/PID").arg(pid.to_string()).arg("/T");
if force {
command.arg("/F");
}
let status = command
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()?;
if status.success() {
Ok(())
} else {
// taskkill 对已退出的进程返回非 0,这不值得中断关闭流程。
Err(io::Error::other(format!("taskkill 退出码 {status}")))
}
}
}
#[cfg(unix)]
mod imp {
use super::*;
pub(super) fn signal(pid: u32, force: bool) -> io::Result<()> {
let flag = if force { "-KILL" } else { "-TERM" };
let mut delivered = false;
// 验过的独立进程组:一次覆盖组长 + 它的全部后代。
if let Some(pgid) = group_kill_target(pid) {
let group = format!("-{pgid}");
delivered |= run_kill(flag, &["--", &group])?;
}
// 单 pid 兜底永远执行:组杀不可用/进程已不在组里时靠它。
let single = pid.to_string();
delivered |= run_kill(flag, &[&single])?;
if delivered {
Ok(())
} else {
// 常见于“进程已经退出”,不值得中断关闭流程;但绝不谎报成功。
Err(io::Error::other(format!(
"kill {flag} {pid} 没有送达(进程可能已退出)"
)))
}
}
/// 发一发 `kill <flag> <args...>`,返回是否送达。
///
/// `kill` 不存在时给**明确**错误而不是静默失败:否则调用方只会看到“进程仍在运行”,
/// 排查方向会被带偏(M48 之前的红灯就是这么来的)。
fn run_kill(flag: &str, args: &[&str]) -> io::Result<bool> {
match quiet("kill")
.arg(flag)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
{
Ok(status) => Ok(status.success()),
Err(err) if err.kind() == io::ErrorKind::NotFound => Err(io::Error::new(
io::ErrorKind::NotFound,
"找不到 kill 命令:POSIX 进程树回收依赖 procps(Linux)或系统自带 kill(macOS/BSD)",
)),
Err(err) => Err(err),
}
}
}
#[cfg(not(any(windows, unix)))]
mod imp {
use super::*;
pub(super) fn signal(_pid: u32, _force: bool) -> io::Result<()> {
Err(io::Error::other("当前平台不支持进程树 kill"))
}
}