Compare commits

...
2 Commits
Author SHA1 Message Date
AskaEth 38832df095 Merge branch 'feat/p3-fix-posix-tree-kill-ci-red-posixkill' 2026-11-01 10:48:15 +08:00
AskaEth 6ce825db8d fix(launch): M48 —— POSIX 组杀假成功导致 stop() 空等超时
CI test (ubuntu-latest) 自 M43 起红的根因不是「组杀打到自身进程组」,
而是 procps-ng 的 kill 把裸 `-<pgid>` 当成又一个信号选项吞掉:退出码 0、
什么都没发。旧实现据此提前 return,单 pid 兜底永远不执行,于是
process_smoke::stop_kills_process_tree 与
pipeline_cases::pipeline_stop_records_killed_exit 只能干等
graceful+5s 预算后报「强制结束后进程仍在运行」。

- process.rs:Unix 分支用 CommandExt::process_group(0)(std 1.64+ 安全
  API,无需 pre_exec/unsafe)让子进程自成一个进程组,与 Windows 的
  CREATE_NEW_PROCESS_GROUP 对称;这也让「树」回收真正覆盖后代。
- tree_kill.rs:组杀改用 `kill -TERM -- -<pgid>`(`--` 不可省),并且
  先过 group_kill_target 三重验证——目标必须是自己的进程组组长、必须
  读得到我们自己的组、目标组不能就是我们所在的组;单 pid 兜底改为
  「永远执行」,kill 缺失(NotFound)返回明确错误而不是静默失败。
  pid==0 守卫从 kill_tree 提到 terminate/force_kill 共用入口。
- 测试钉子(Linux 门住):spawn 后子进程 pgid == 自己的 pid 且 !=
  测试进程 pgid;组杀只接受验证过的独立组;组杀覆盖孙进程且不碰我们
  自己的组;同组子进程靠单 pid 兜底仍能被干掉;pid 0 一律拒绝。

验证:rust:1-bookworm 容器(ull-linux-target 热缓存)cargo fmt --check、
cargo clippy --workspace --all-targets -D warnings、cargo test -p
unlucky-core --test launch(82 passed,两条原红灯转绿)、cargo test
--workspace --all-targets --no-fail-fast 不再被 SIGTERM 打断;Windows
本机 fmt/clippy/cargo test -p unlucky-core 全绿。
2026-11-01 10:32:18 +08:00
5 changed files with 351 additions and 21 deletions

No files matched your search

+9
View File
@@ -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(),
+100 -21
View File
@@ -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),
}
}
+1
View File
@@ -13,3 +13,4 @@ mod process_smoke;
mod pump_buffer;
mod session_events;
mod sink_cases;
mod tree_kill_cases;
+40
View File
@@ -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");
+201
View File
@@ -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();
}
}