diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs index 3a890ead3..d963ea811 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs @@ -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> = 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 { - 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::().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); diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests/owner_fixture_cleanup.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests/owner_fixture_cleanup.rs new file mode 100644 index 000000000..af07502cc --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests/owner_fixture_cleanup.rs @@ -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 { + 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::().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" + ); + } +} diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index fcd7615c4..a7af72f70 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -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 属于持久化恢复合同,修改工具默认参数后仍须验证旧动作恢复不重复提交、不因默认值变化被误判为新意图。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 65a42c942..5d13164cd 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -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,不能把过渡状态当作工具链已闭合。