Compare commits
2
Commits
eaa1414847
...
38832df095
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38832df095 | ||
|
|
6ce825db8d |
No files matched your search
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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