diff --git a/crates/core/src/launch/process.rs b/crates/core/src/launch/process.rs index aad3409..79a9582 100644 --- a/crates/core/src/launch/process.rs +++ b/crates/core/src/launch/process.rs @@ -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 -- -` 才能覆盖整棵子树。`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(), diff --git a/crates/core/src/launch/tree_kill.rs b/crates/core/src/launch/tree_kill.rs index 7988cdc..278a0ba 100644 --- a/crates/core/src/launch/tree_kill.rs +++ b/crates/core/src/launch/tree_kill.rs @@ -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 -`(命不中是常态,因为我们没有 `setsid`——`unsafe` pre_exec 被 -//! workspace `unsafe_code = deny` 挡住)→ 宽限 → `kill -KILL -`,再退到单 pid。 +//! - POSIX:子进程由 [`crate::launch::process`] 用 `CommandExt::process_group(0)` 放进**自己的** +//! 进程组(与 Windows 的 `CREATE_NEW_PROCESS_GROUP` 对称,std 1.64+ 的安全 API),因此 +//! `kill -TERM -- -` 一次覆盖整棵子树 → 宽限 → `kill -KILL -- -`,再补一发单 pid。 //! //! 两个平台都先尝试“温和”,宽限期后再“强制”。 //! +//! # 两条来自 CI 的硬约束 +//! +//! 1. **组杀必须先验组**([`group_kill_target`]):目标进程必须是它自己进程组的**组长**, +//! 且该组不能是我们所在的组。否则一次 `kill -- -` 会把测试进程(CI 容器里全体进程 +//! 常常共用 pgid 1)一起带走——M48 之前 `test (ubuntu-latest)` 就是这么被 SIGTERM 打断的。 +//! 验不过就只发单 pid。 +//! 2. **`--` 不能省**:procps-ng 的 `kill` 会把裸 `-` 当成又一个信号选项吞掉,**退出码 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`)——否则 `-` 指的不是“它的”子树; +/// 2. 必须能读到我们自己所在的进程组; +/// 3. 目标组不能就是我们所在的组——否则信号会打到自己人身上。 +#[cfg(unix)] +pub fn group_kill_target(pid: u32) -> Option { + 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 { + // `/proc//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 { + 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" }; - // 先按进程组(`-`);没有 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> { + /// 发一发 `kill `,返回是否送达。 + /// + /// `kill` 不存在时给**明确**错误而不是静默失败:否则调用方只会看到“进程仍在运行”, + /// 排查方向会被带偏(M48 之前的红灯就是这么来的)。 + fn run_kill(flag: &str, args: &[&str]) -> io::Result { 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), } } diff --git a/crates/core/tests/launch/main.rs b/crates/core/tests/launch/main.rs index a4a2d7b..f6d53ac 100644 --- a/crates/core/tests/launch/main.rs +++ b/crates/core/tests/launch/main.rs @@ -13,3 +13,4 @@ mod process_smoke; mod pump_buffer; mod session_events; mod sink_cases; +mod tree_kill_cases; diff --git a/crates/core/tests/launch/process_smoke.rs b/crates/core/tests/launch/process_smoke.rs index dc954b4..fcf959b 100644 --- a/crates/core/tests/launch/process_smoke.rs +++ b/crates/core/tests/launch/process_smoke.rs @@ -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//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"); diff --git a/crates/core/tests/launch/tree_kill_cases.rs b/crates/core/tests/launch/tree_kill_cases.rs new file mode 100644 index 0000000..052da5a --- /dev/null +++ b/crates/core/tests/launch/tree_kill_cases.rs @@ -0,0 +1,201 @@ +//! 进程树回收的安全钉子:组杀只打“目标自己的独立进程组”,绝不命中我们所在的组。 +//! +//! 这些用例钉的是 M48 的两条根因: +//! - 子进程必须自成一个进程组(`process_group(0)`),否则 `kill -- -` 没有可用的组; +//! - 组杀前必须验组([`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//stat` 里 `)` 之后的字段(state ppid pgrp ...)。 + fn stat_tail(pid: u32) -> Option> { + 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 { + 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 不是合法目标"); + + // 同组的子进程:它不是组长,`-` 指向的不是“它的”组。 + 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, + "非组长子进程不能走组杀(`-` 会打到别人的组)" + ); + + // 独立组的子进程:合法的组杀目标。 + 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 { + 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(); + } +}