修复进程会话测试失败时的残留进程清理
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"
);
}
}
@@ -61,6 +61,8 @@ AGC 预览快捷操作的界面测试按独立命令或有状态短流程注册
Rust 分片失败日志保留有界的失败详情,包括 panic 位置、断言和最终通过/失败数量;分片选中数量标为 selected,避免误读为失败数量。修改分片日志时运行 `node --test apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.test.mjs`,用最小 Rust fixture 验证失败详情和成功摘要。
Linux process-session 的 owner SIGKILL 测试在启动 owner 后立即建立清理 guard,正常结束和 panic 展开都必须终止并回收 owner,再按独立临时项目目录清理残留进程。先完成「杀掉 owner 后子进程自行退出」的原有断言,guard 只在退出测试作用域时兜底,不得提前清理子树使生命周期回归假绿;清理本身不得 panic 或无限等待。
AGC 运行时配置默认值调整时,同步核对 Rust 默认值、分发配置模板、设置弹窗默认草稿和 `runtime-settings.suite.ts` 的恢复默认断言;显式传入旧值的配置读取用例仍验证原值保留,不批量替换测试数据。
AGC 测试构造单 HTML 项目时,必须在初始化之前写入 HTML,避免自动建立 npm 工程;npm 预览和导出测试应提供 dist 产物。已有图片生成 pending/operation 属于持久化恢复合同,修改工具默认参数后仍须验证旧动作恢复不重复提交、不因默认值变化被误判为新意图。
@@ -288,6 +288,8 @@ npm run check
### Gitea Actions PR 门禁
Linux process-session 的 owner SIGKILL 用例必须在启动 owner 后立即建立测试清理 guard:正常退出或断言 panic 时终止、回收 owner,并在有界时间内清理其独立临时项目目录中的残留进程。原有「owner 退出后子进程自行消失」断言在兜底清理之前执行,不能由 guard 代替生产生命周期验证。清理覆盖 panic 路径及临时项目间隔离,且不得因清理失败再次 panic。
仓库级 Gitea Actions 工作流固定为 `.gitea/workflows/project-ci.yml`,在向 `master` 推送、创建或更新 PR,以及手工触发时运行。工作流拆成九个必须通过的 job。job 声明顺序就是 runner 领取顺序,因此把两条 AGC 壳 Rust lane 排在最前:并发槽位不足时它们必须最先开始,AGC 侧的关键路径才由自己而不是由排队决定。
所有 CI job 和 Jenkins Web Build 在根 workspace 安装前都必须确认 `npm --version``10.9.7`。Gitea job 使用预构建镜像内的固定版本;Jenkins Web Build 在每个独立 `bash -lc` 中 source `scripts/jenkins-prepare-npm-env.sh`,首次为 Jenkins 运行用户的版本隔离目录引导同版 npm,后续复用并把该 `bin` 放到 `PATH` 首位。旧固定镜像缺少版本元数据时只能报告 `npm_version=partial` 并由当前 job 的根 `npm ci` 继续校验 lock,不能把过渡状态当作工具链已闭合。