修复进程会话测试失败时的残留进程清理
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled

为 owner 测试添加作用域清理,确保正常退出和 panic 时回收进程
保留子进程自行退出断言,并限定残留清理到独立临时项目
增加 panic 清理与跨项目隔离回归测试
同步更新开发运维文档和共享测试约定
This commit is contained in:
2026-09-22 03:01:15 +00:00
parent 0a0b7d08c2
commit 56cb6ae48c
4 changed files with 143 additions and 17 deletions
@@ -2,6 +2,11 @@ use super::*;
#[cfg(target_os = "linux")]
use std::process::Stdio;
#[cfg(target_os = "linux")]
mod owner_fixture_cleanup;
#[cfg(target_os = "linux")]
use owner_fixture_cleanup::{project_processes, OwnerFixtureCleanup};
static PROCESS_SESSION_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn process_session_test_guard() -> std::sync::MutexGuard<'static, ()> {
@@ -1743,20 +1748,6 @@ fn process_session_runner_owner_fixture() {
#[cfg(target_os = "linux")]
#[test]
fn process_session_owner_sigkill_leaves_no_child_process() {
fn project_processes(root: &Path) -> Vec<i32> {
let canonical_root = fs::canonicalize(root).expect("canonical test project");
fs::read_dir("/proc")
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| {
let process_id = entry.file_name().to_string_lossy().parse::<i32>().ok()?;
let cwd = fs::read_link(entry.path().join("cwd")).ok()?;
(cwd == canonical_root).then_some(process_id)
})
.collect()
}
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
init_local_game_project_at(root, "owner-process-project", "Owner Process Project")
@@ -1787,6 +1778,10 @@ setInterval(() => {}, 1000);
.stderr(Stdio::null())
.spawn()
.expect("spawn owner fixture test process");
let cleanup = OwnerFixtureCleanup {
owner: &mut owner,
root,
};
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while (!root.join("owner-ready").is_file() || project_processes(root).is_empty())
&& std::time::Instant::now() < deadline
@@ -1799,9 +1794,9 @@ setInterval(() => {}, 1000);
"sandbox child should be visible from host /proc"
);
let owner_pid = i32::try_from(owner.id()).expect("owner pid");
assert_eq!(unsafe { libc::kill(owner_pid, libc::SIGKILL) }, 0);
owner.wait().expect("reap owner fixture");
// Linux Child::kill 发送 SIGKILL;先检查真实子树回收,再由 guard 兜底。
cleanup.owner.kill().expect("SIGKILL owner fixture");
cleanup.owner.wait().expect("reap owner fixture");
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let remaining = project_processes(root);
@@ -0,0 +1,127 @@
use std::fs;
use std::path::Path;
use std::process::Child;
use std::thread;
use std::time::{Duration, Instant};
pub(super) fn project_processes(root: &Path) -> Vec<i32> {
let Ok(canonical_root) = fs::canonicalize(root) else {
return Vec::new();
};
fs::read_dir("/proc")
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| {
let process_id = entry.file_name().to_string_lossy().parse::<i32>().ok()?;
if process_id <= 1 || process_id == std::process::id() as i32 {
return None;
}
let cwd = fs::read_link(entry.path().join("cwd")).ok()?;
(cwd == canonical_root).then_some(process_id)
})
.collect()
}
pub(super) struct OwnerFixtureCleanup<'a> {
pub(super) owner: &'a mut Child,
pub(super) root: &'a Path,
}
impl Drop for OwnerFixtureCleanup<'_> {
fn drop(&mut self) {
let _ = self.owner.kill();
let _ = self.owner.wait();
// 正常路径先验证子进程自行退出;这里只兜底作用域退出(包括 panic)后的残留。
// 项目目录由每条用例独占,不能按进程名清理其他用例或开发进程。
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let remaining = project_processes(self.root);
if remaining.is_empty() {
return;
}
for process_id in &remaining {
unsafe {
libc::kill(*process_id, libc::SIGKILL);
}
}
if Instant::now() >= deadline {
// Drop 可能在 panic 展开期间执行,不能再次 panic。
use std::io::Write;
let _ = writeln!(
std::io::stderr(),
"owner fixture cleanup timed out: pids={remaining:?}"
);
return;
}
thread::sleep(Duration::from_millis(25));
}
}
}
#[test]
fn owner_fixture_cleanup_reaps_processes_on_panic_without_touching_other_projects() {
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::process::{Command, Stdio};
// 回归夹具自己的回收不能依赖被测 guard,否则 guard 回归时测试也会泄漏。
struct Sleeper(Child);
impl Drop for Sleeper {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn sleeper(root: &Path) -> Sleeper {
Sleeper(
Command::new("sleep")
.arg("60")
.current_dir(root)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn cleanup fixture"),
)
}
// 同时覆盖 owner 刚启动就失败,以及已有残留进程时失败。
for has_residual in [false, true] {
let project = tempfile::tempdir().expect("cleanup project");
let other_project = tempfile::tempdir().expect("unrelated project");
let mut owner = sleeper(project.path());
let cleanup = OwnerFixtureCleanup {
owner: &mut owner.0,
root: project.path(),
};
// 故意不依赖 owner 退出监测,验证兜底能清理仍留在项目目录的进程。
let mut residual = has_residual.then(|| sleeper(project.path()));
let mut other = sleeper(other_project.path());
let result = catch_unwind(AssertUnwindSafe(move || {
let _cleanup = cleanup;
panic!("simulate an assertion failure before owner shutdown");
}));
let owner_status = owner.0.try_wait();
let residual_status = residual.as_mut().map(|child| child.0.try_wait());
let other_status = other.0.try_wait();
// 即使 guard 回归,先收口本测试持有的进程再断言,避免回归用例自身泄漏。
drop(owner);
drop(residual);
drop(other);
assert!(result.is_err());
assert!(matches!(owner_status, Ok(Some(_))), "owner must exit");
if has_residual {
assert!(
matches!(residual_status, Some(Ok(Some(_)))),
"residual process must exit"
);
}
assert!(
matches!(other_status, Ok(None)),
"other project must survive"
);
}
}