Compare commits
1
Commits
eaa1414847
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f8382565e |
No files matched your search
@@ -83,9 +83,48 @@ jobs:
|
||||
set -euo pipefail
|
||||
# +crt-static 是否真的生效,只看导入表:出现 VCRUNTIME/MSVCP/msvcr/ucrtbase 说明还得装
|
||||
# 可再发行包,那就不是"单文件"了,直接 fail。
|
||||
DUMPBIN=$(command -v dumpbin || true)
|
||||
#
|
||||
# dumpbin 定位链(三级,全不命中才 fail —— 门禁不降级,只是把"猜路径"换成官方定位):
|
||||
# ① vswhere:VS 官方定位器,安装器自身路径固定,与 VS 版本/版次/安装布局无关;
|
||||
# ② find 兜底:Program Files 与 Program Files (x86) 两个 VS 根都扫(Build Tools 落在 x86 根);
|
||||
# ③ PATH:镜像若已把工具链预置进 PATH 则直接可用。
|
||||
# PATH 排最后是刻意的:PATH 最不可控,官方定位器优先。
|
||||
VSWHERE="C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
DUMPBIN=""
|
||||
VIA=""
|
||||
if [ -x "$VSWHERE" ]; then
|
||||
# -find 的模式优先用反斜杠(vswhere 官方文档即此形式),再试正斜杠以规避分隔符差异。
|
||||
for pattern in 'VC\Tools\MSVC\*\bin\Hostx64\x64\dumpbin.exe' \
|
||||
'VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe'; do
|
||||
hit=$("$VSWHERE" -latest -products '*' -find "$pattern" 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$hit" ]; then
|
||||
DUMPBIN=${hit//\\//}
|
||||
VIA="vswhere"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -z "$DUMPBIN" ]; then
|
||||
DUMPBIN=$(ls -d "C:/Program Files/Microsoft Visual Studio/"*/VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe 2>/dev/null | head -n1 || true)
|
||||
# 镜像里的 VS 根形如 <年>/<版次>(2022/Enterprise、2022/BuildTools……),
|
||||
# 固定层数的 glob 会在 <年>/<版次> 这两级踩空,所以按路径模式扫。
|
||||
for root in "C:/Program Files/Microsoft Visual Studio" \
|
||||
"C:/Program Files (x86)/Microsoft Visual Studio"; do
|
||||
[ -d "$root" ] || continue
|
||||
hit=$(find "$root" -maxdepth 11 -type f \
|
||||
-path '*/VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe' 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$hit" ]; then
|
||||
DUMPBIN=$hit
|
||||
VIA="find: $root"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [ -z "$DUMPBIN" ]; then
|
||||
hit=$(command -v dumpbin || true)
|
||||
if [ -n "$hit" ]; then
|
||||
DUMPBIN=$hit
|
||||
VIA="PATH"
|
||||
fi
|
||||
fi
|
||||
# 工具缺失同样 fail:windows-latest 自带 VS 工具链,基本找得到;
|
||||
# "找不到就跳过"等于让这道门禁在最需要它的时候静默失效。
|
||||
@@ -93,6 +132,7 @@ jobs:
|
||||
echo "::error::找不到 dumpbin,无法校验导入表(静态 CRT 门禁不降级)"
|
||||
exit 1
|
||||
fi
|
||||
echo "dumpbin: $DUMPBIN(定位自 $VIA)"
|
||||
"$DUMPBIN" -nologo -dependents target/release/unlucky-launcher.exe | tee dependents.txt
|
||||
if grep -Eiq 'VCRUNTIME|MSVCP|MSVCR|ucrtbase\.dll' dependents.txt; then
|
||||
echo "::error::静态 CRT 未生效:exe 仍动态依赖 VC++ 可再发行包"
|
||||
|
||||
@@ -186,13 +186,24 @@ pub fn dedupe(runtimes: Vec<JavaRuntime>) -> Vec<JavaRuntime> {
|
||||
kept
|
||||
}
|
||||
|
||||
/// 去重键:去掉 Windows 的 `\\?\` 前缀、统一分隔符、大小写不敏感(Windows)。
|
||||
/// 去重键:剥掉 Windows 的 `\\?\` 前缀、分隔符一律折成 `/`、大小写只在 Windows 折叠。
|
||||
///
|
||||
/// 前两步与平台无关:同一条 `bin/java[.exe]` 用 `/` 还是 `\` 拼出来都是同一个文件,
|
||||
/// 键必须相同——`JAVA_HOME` 的写法与扫盘拼出来的路径常在这点上不一致,键不齐就重复列。
|
||||
/// 大小写折叠**只能**跟着平台走:NTFS 不区分大小写,而 POSIX 上 `Java` 与 `java` 是两个
|
||||
/// 目录,折了会把两份真实安装误并成一个。
|
||||
///
|
||||
/// 代价(已知且有意的取舍):POSIX 上文件名里的字面反斜杠也会被当分隔符。JDK 安装路径带
|
||||
/// 反斜杠属于病态情况,换来的是「同一条 java 的两种拼法永远同键」这条平台无关的不变量。
|
||||
#[must_use]
|
||||
pub fn path_key(path: &Path) -> String {
|
||||
let text = path.display().to_string();
|
||||
let text = text.strip_prefix(r"\\?\").unwrap_or(&text).to_string();
|
||||
let text = text
|
||||
.strip_prefix(r"\\?\")
|
||||
.unwrap_or(&text)
|
||||
.replace('\\', "/");
|
||||
if cfg!(windows) {
|
||||
text.replace('/', "\\").to_ascii_lowercase()
|
||||
text.to_ascii_lowercase()
|
||||
} else {
|
||||
text
|
||||
}
|
||||
@@ -236,11 +247,56 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_key_normalises_separators() {
|
||||
let left = path_key(std::path::Path::new("C:/a/b/java.exe"));
|
||||
let right = path_key(std::path::Path::new("C:\\a\\b\\java.exe"));
|
||||
assert_eq!(left, right);
|
||||
assert!(!path_key(std::path::Path::new(r"\\?\C:\a\java.exe")).starts_with(r"\\"));
|
||||
fn path_key_normalises_separators_on_every_platform() {
|
||||
// 平台无关的不变量:同一条 `bin/java` 用 `/` 还是 `\` 拼出来,键必须相同。
|
||||
// (M49:原来这里只在 Windows 上成立,Linux 上 `\` 原样留着 → 断言挂。)
|
||||
let forward = path_key(std::path::Path::new("C:/a/b/java.exe"));
|
||||
let backward = path_key(std::path::Path::new("C:\\a\\b\\java.exe"));
|
||||
assert_eq!(forward, backward);
|
||||
assert!(
|
||||
!forward.contains('\\'),
|
||||
"键里不该残留反斜杠(与平台无关):{forward}"
|
||||
);
|
||||
assert!(
|
||||
!forward.starts_with(r"\\"),
|
||||
"`\\\\?\\` 前缀不进键:{forward}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_install_normalises_to_one_key_whatever_the_spelling() {
|
||||
// 回归钉子:`JAVA_HOME` 与扫盘常对同一个 java 给出不同写法,四种拼法必须同键。
|
||||
let spellings = [
|
||||
"C:/Program Files/Java/jdk-21/bin/java.exe",
|
||||
r"C:\Program Files\Java\jdk-21\bin\java.exe",
|
||||
r"\\?\C:\Program Files\Java\jdk-21\bin\java.exe",
|
||||
r"C:\Program Files\Java\jdk-21/bin/java.exe",
|
||||
];
|
||||
let key = path_key(std::path::Path::new(spellings[0]));
|
||||
for spelling in spellings {
|
||||
assert_eq!(
|
||||
path_key(std::path::Path::new(spelling)),
|
||||
key,
|
||||
"同一安装的两种写法必须同键:{spelling}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_key_folds_case_only_where_the_filesystem_does() {
|
||||
// 大小写折叠跟平台走,两侧都钉住:NTFS 不区分大小写 → 折;
|
||||
// POSIX 上 `Java` 与 `java` 是两个目录 → 不折(折了会把两份真实安装误并)。
|
||||
let upper = path_key(std::path::Path::new(
|
||||
"C:/Program Files/Java/jdk-21/bin/java.exe",
|
||||
));
|
||||
let lower = path_key(std::path::Path::new(
|
||||
"c:/program files/java/jdk-21/bin/java.exe",
|
||||
));
|
||||
if cfg!(windows) {
|
||||
assert_eq!(upper, lower, "Windows:大小写不同的同一路径应同键");
|
||||
} else {
|
||||
assert_ne!(upper, lower, "POSIX:大小写是有效区别,不应同键");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -81,6 +81,15 @@ impl RunningProcess {
|
||||
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(),
|
||||
|
||||
@@ -6,27 +6,39 @@
|
||||
//! - 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:`kill -TERM -<pid>`(命不中是常态,因为我们没有 `setsid`——`unsafe` pre_exec 被
|
||||
//! workspace `unsafe_code = deny` 挡住)→ 宽限 → `kill -KILL -<pid>`,再退到单 pid。
|
||||
//! - 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` 轮询),本模块只提供发送动作。
|
||||
|
||||
use std::io;
|
||||
use std::process::Command;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 温和关闭(Windows 无 `/F`;POSIX `SIGTERM`)。
|
||||
pub fn terminate(pid: u32) -> io::Result<()> {
|
||||
check_pid(pid)?;
|
||||
imp::signal(pid, false)
|
||||
}
|
||||
|
||||
/// 强制关闭(Windows `/F`;POSIX `SIGKILL`),并尽量覆盖整棵进程树。
|
||||
pub fn force_kill(pid: u32) -> io::Result<()> {
|
||||
check_pid(pid)?;
|
||||
imp::signal(pid, true)
|
||||
}
|
||||
|
||||
@@ -34,17 +46,70 @@ pub fn force_kill(pid: u32) -> io::Result<()> {
|
||||
///
|
||||
/// 注意:返回值不代表“进程已经死了”(见模块注释)。需要准确判定请用
|
||||
/// [`crate::launch::process::RunningProcess::stop`]。
|
||||
pub fn kill_tree(pid: u32, grace: Duration) -> io::Result<()> {
|
||||
if pid == 0 {
|
||||
return Err(io::Error::other("无效的 pid 0"));
|
||||
}
|
||||
pub fn kill_tree(pid: u32, grace: std::time::Duration) -> io::Result<()> {
|
||||
check_pid(pid)?;
|
||||
let _ = terminate(pid);
|
||||
if !grace.is_zero() {
|
||||
thread::sleep(grace);
|
||||
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 = Command::new("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::*;
|
||||
@@ -79,28 +144,42 @@ mod imp {
|
||||
|
||||
pub(super) fn signal(pid: u32, force: bool) -> io::Result<()> {
|
||||
let flag = if force { "-KILL" } else { "-TERM" };
|
||||
// 先按进程组(`-<pid>`);没有 setsid 时通常失败,再退到单 pid。
|
||||
if run_kill(flag, &format!("-{pid}"))?.is_some() {
|
||||
return Ok(());
|
||||
let mut delivered = false;
|
||||
// 验过的独立进程组:一次覆盖组长 + 它的全部后代。
|
||||
if let Some(pgid) = group_kill_target(pid) {
|
||||
let group = format!("-{pgid}");
|
||||
delivered |= run_kill(flag, &["--", &group])?;
|
||||
}
|
||||
match run_kill(flag, &pid.to_string())? {
|
||||
Some(status) if status.success() => Ok(()),
|
||||
Some(status) => Err(io::Error::other(format!("kill 退出码 {status}"))),
|
||||
None => Err(io::Error::other("找不到 kill 命令")),
|
||||
// 单 pid 兜底永远执行:组杀不可用/进程已不在组里时靠它。
|
||||
let single = pid.to_string();
|
||||
delivered |= run_kill(flag, &[&single])?;
|
||||
if delivered {
|
||||
Ok(())
|
||||
} else {
|
||||
// 常见于“进程已经退出”,不值得中断关闭流程;但绝不谎报成功。
|
||||
Err(io::Error::other(format!(
|
||||
"kill {flag} {pid} 没有送达(进程可能已退出)"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn run_kill(flag: &str, target: &str) -> io::Result<Option<std::process::ExitStatus>> {
|
||||
/// 发一发 `kill <flag> <args...>`,返回是否送达。
|
||||
///
|
||||
/// `kill` 不存在时给**明确**错误而不是静默失败:否则调用方只会看到“进程仍在运行”,
|
||||
/// 排查方向会被带偏(M48 之前的红灯就是这么来的)。
|
||||
fn run_kill(flag: &str, args: &[&str]) -> io::Result<bool> {
|
||||
match Command::new("kill")
|
||||
.arg(flag)
|
||||
.arg(target)
|
||||
.args(args)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
{
|
||||
Ok(status) if status.success() => Ok(Some(status)),
|
||||
Ok(_) => Ok(None),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,17 +96,20 @@ fn scanned_java_is_still_used_when_nothing_else_matches() {
|
||||
#[test]
|
||||
fn dedupe_collapses_one_install_found_twice() {
|
||||
// `JAVA_HOME` 指向 Program Files 里的 JDK → 同一个 java 不应列两次。
|
||||
// 第二条的「另一种写法」随平台给:Windows 上大小写和分隔符都可能不一致(NTFS 不区分
|
||||
// 大小写),POSIX 上大小写是有效区别,只能靠分隔符拼出同一路径的第二种写法。
|
||||
let duplicate = if cfg!(windows) {
|
||||
"c:/program files/java/jdk-21/bin/java.exe"
|
||||
} else {
|
||||
"C:\\Program Files\\Java\\jdk-21/bin/java.exe"
|
||||
};
|
||||
let candidates = discover::dedupe(vec![
|
||||
runtime(
|
||||
"C:/Program Files/Java/jdk-21/bin/java.exe",
|
||||
21,
|
||||
JavaOrigin::JavaHome,
|
||||
),
|
||||
runtime(
|
||||
"c:/program files/java/jdk-21/bin/java.exe",
|
||||
21,
|
||||
JavaOrigin::System,
|
||||
),
|
||||
runtime(duplicate, 21, JavaOrigin::System),
|
||||
]);
|
||||
assert_eq!(candidates.len(), 1);
|
||||
assert_eq!(candidates[0].origin, JavaOrigin::JavaHome);
|
||||
|
||||
@@ -13,3 +13,4 @@ mod process_smoke;
|
||||
mod pump_buffer;
|
||||
mod session_events;
|
||||
mod sink_cases;
|
||||
mod tree_kill_cases;
|
||||
@@ -111,6 +111,46 @@ fn stop_kills_process_tree() {
|
||||
assert!(!child.is_running());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn spawn_puts_child_in_its_own_process_group() {
|
||||
/// `/proc/<pid>/stat` 里的 `pgrp` 字段。
|
||||
fn pgid(pid: u32) -> u32 {
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).expect("读 /proc stat");
|
||||
let tail = &stat[stat.rfind(')').expect("stat 应有 comm 括号") + 1..];
|
||||
tail.split_whitespace()
|
||||
.nth(2)
|
||||
.expect("stat 应有 pgrp 字段")
|
||||
.parse()
|
||||
.expect("pgrp 应是数字")
|
||||
}
|
||||
|
||||
let mut child = RunningProcess::spawn(
|
||||
&shell_command("sleep 30"),
|
||||
None,
|
||||
Arc::new(NullSink),
|
||||
config(),
|
||||
)
|
||||
.expect("spawn 应成功");
|
||||
let pid = child.pid();
|
||||
let own_pgid = pgid(std::process::id());
|
||||
|
||||
assert_eq!(
|
||||
pgid(pid),
|
||||
pid,
|
||||
"子进程应是自己的进程组组长(process_group(0))"
|
||||
);
|
||||
assert_ne!(pgid(pid), own_pgid, "子进程不能挂在测试进程的进程组上");
|
||||
assert_eq!(
|
||||
unlucky_core::launch::tree_kill::group_kill_target(pid),
|
||||
Some(pid),
|
||||
"独立组的组长才是合法的组杀目标"
|
||||
);
|
||||
|
||||
child.stop().expect("停止应成功");
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_reports_missing_program() {
|
||||
let mut command = shell_command("echo hi");
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! 进程树回收的安全钉子:组杀只打“目标自己的独立进程组”,绝不命中我们所在的组。
|
||||
//!
|
||||
//! 这些用例钉的是 M48 的两条根因:
|
||||
//! - 子进程必须自成一个进程组(`process_group(0)`),否则 `kill -- -<pgid>` 没有可用的组;
|
||||
//! - 组杀前必须验组([`tree_kill::group_kill_target`]),否则 CI 容器里“全体进程 pgid=1”
|
||||
//! 的那种环境会被一发组杀连测试进程一起带走。
|
||||
//!
|
||||
//! `/proc` 只有 Linux 有,进程组相关的用例都用 `#[cfg(target_os = "linux")]` 门住;
|
||||
//! `pid == 0` 的守卫与平台无关,各平台都跑。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use unlucky_core::launch::tree_kill;
|
||||
|
||||
/// `pid` 0 在 POSIX `kill` 里表示“当前进程组”——绝不能顺手发出去。
|
||||
#[test]
|
||||
fn pid_zero_is_rejected() {
|
||||
assert!(tree_kill::terminate(0).is_err(), "terminate(0) 必须拒绝");
|
||||
assert!(tree_kill::force_kill(0).is_err(), "force_kill(0) 必须拒绝");
|
||||
assert!(
|
||||
tree_kill::kill_tree(0, Duration::ZERO).is_err(),
|
||||
"kill_tree(0) 必须拒绝"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use super::*;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
|
||||
use unlucky_core::launch::tree_kill::group_kill_target;
|
||||
|
||||
/// `/proc/<pid>/stat` 里 `)` 之后的字段(state ppid pgrp ...)。
|
||||
fn stat_tail(pid: u32) -> Option<Vec<String>> {
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let tail = stat.get(stat.rfind(')')? + 1..)?;
|
||||
Some(tail.split_whitespace().map(str::to_string).collect())
|
||||
}
|
||||
|
||||
fn pgid(pid: u32) -> Option<u32> {
|
||||
stat_tail(pid)?.get(2)?.parse().ok()
|
||||
}
|
||||
|
||||
/// 还活着(僵尸不算:已经死了只是没人 wait)。
|
||||
fn running(pid: u32) -> bool {
|
||||
matches!(stat_tail(pid).as_deref().and_then(|fields| fields.first().map(String::as_str)), Some(state) if state != "Z")
|
||||
}
|
||||
|
||||
fn wait_gone(pid: u32, budget: Duration) -> bool {
|
||||
let deadline = std::time::Instant::now() + budget;
|
||||
while std::time::Instant::now() < deadline {
|
||||
if !running(pid) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
!running(pid)
|
||||
}
|
||||
|
||||
/// 组长的子进程(`process_group(0)`):自带一个组。
|
||||
fn spawn_grouped(script: &str) -> Child {
|
||||
use std::os::unix::process::CommandExt;
|
||||
Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(script)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.expect("spawn 应成功")
|
||||
}
|
||||
|
||||
/// 和测试进程同组的子进程(不调 `process_group`):组长不是它。
|
||||
fn spawn_in_our_group(script: &str) -> Child {
|
||||
Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(script)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn 应成功")
|
||||
}
|
||||
|
||||
/// 测试失败时也别把 `sleep 30` 留在机器上。
|
||||
struct KillOnDrop(u32);
|
||||
|
||||
impl Drop for KillOnDrop {
|
||||
fn drop(&mut self) {
|
||||
let _ = tree_kill::force_kill(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 自己的进程组号:组杀安全的基准。
|
||||
fn self_pgid() -> u32 {
|
||||
pgid(std::process::id()).expect("/proc/self/stat 应可读")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_kill_target_accepts_only_a_verified_own_group() {
|
||||
let own_pgid = self_pgid();
|
||||
// 我们自己所在的组永远不是合法目标(哪怕我们正好是组长)。
|
||||
assert_eq!(
|
||||
group_kill_target(std::process::id()),
|
||||
None,
|
||||
"自己的进程号绝不能成为组杀目标"
|
||||
);
|
||||
assert_eq!(
|
||||
group_kill_target(own_pgid),
|
||||
None,
|
||||
"自己的进程组绝不能成为组杀目标"
|
||||
);
|
||||
assert_eq!(group_kill_target(0), None, "pid 0 不是合法目标");
|
||||
|
||||
// 同组的子进程:它不是组长,`-<pid>` 指向的不是“它的”组。
|
||||
let mut child = spawn_in_our_group("sleep 30");
|
||||
let pid = child.id();
|
||||
let _guard = KillOnDrop(pid);
|
||||
assert_eq!(pgid(pid), Some(own_pgid), "同组子进程应继承我们的 pgid");
|
||||
assert_eq!(
|
||||
group_kill_target(pid),
|
||||
None,
|
||||
"非组长子进程不能走组杀(`-<pid>` 会打到别人的组)"
|
||||
);
|
||||
|
||||
// 独立组的子进程:合法的组杀目标。
|
||||
let mut grouped = spawn_grouped("sleep 30");
|
||||
let grouped_pid = grouped.id();
|
||||
let _grouped_guard = KillOnDrop(grouped_pid);
|
||||
assert_eq!(pgid(grouped_pid), Some(grouped_pid), "子进程应是组长");
|
||||
assert_eq!(
|
||||
group_kill_target(grouped_pid),
|
||||
Some(grouped_pid),
|
||||
"独立组的组长可以组杀"
|
||||
);
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let _ = grouped.kill();
|
||||
let _ = grouped.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_kill_reaps_grandchildren_without_touching_our_group() {
|
||||
let mut child = spawn_grouped("sleep 30 & sleep 30");
|
||||
let pid = child.id();
|
||||
let _guard = KillOnDrop(pid);
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
|
||||
let children_of = || -> Vec<u32> {
|
||||
std::fs::read_to_string(format!("/proc/{pid}/task/{pid}/children"))
|
||||
.unwrap_or_default()
|
||||
.split_whitespace()
|
||||
.filter_map(|value| value.parse().ok())
|
||||
.collect()
|
||||
};
|
||||
let grandchildren = children_of();
|
||||
assert!(
|
||||
!grandchildren.is_empty(),
|
||||
"shell 应当先 fork 出一个 sleep 子进程:{grandchildren:?}"
|
||||
);
|
||||
for grandchild in &grandchildren {
|
||||
assert_eq!(pgid(*grandchild), Some(pid), "后代必须落在子进程自己的组里");
|
||||
}
|
||||
|
||||
tree_kill::terminate(pid).expect("组杀应送达");
|
||||
assert!(wait_gone(pid, Duration::from_secs(3)), "组长应被组杀带走");
|
||||
for grandchild in &grandchildren {
|
||||
assert!(
|
||||
wait_gone(*grandchild, Duration::from_secs(3)),
|
||||
"组杀应覆盖后代 {grandchild}"
|
||||
);
|
||||
}
|
||||
|
||||
// 组杀没有溢出:我们自己还在原来的组里,而且没有被信号带走。
|
||||
assert_eq!(self_pgid(), pgid(std::process::id()).expect("仍然可读"));
|
||||
assert_ne!(pid, self_pgid(), "目标组和我们的组必须不同");
|
||||
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminate_still_kills_a_child_that_shares_our_group() {
|
||||
let own_pgid = self_pgid();
|
||||
let mut child = spawn_in_our_group("sleep 30");
|
||||
let pid = child.id();
|
||||
let _guard = KillOnDrop(pid);
|
||||
assert_eq!(pgid(pid), Some(own_pgid));
|
||||
|
||||
tree_kill::terminate(pid).expect("单 pid 兜底应送达");
|
||||
assert!(
|
||||
wait_gone(pid, Duration::from_secs(3)),
|
||||
"同组子进程也必须能被单 pid TERM 干掉"
|
||||
);
|
||||
// 走到这里本身就说明“组杀没有命中自身进程组”——否则测试进程已被 TERM。
|
||||
assert_eq!(self_pgid(), pgid(std::process::id()).expect("仍然可读"));
|
||||
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user