From 04a6be5c3dde27ee611a9714d44e7a5fe0e5c3b5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 3 Aug 2026 15:31:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=B9=E5=99=A8=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E7=BB=84=E6=B8=85=E7=90=86=E8=AF=AF=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 区分Linux进程组中的可执行成员与僵尸进程 补齐容器PID 1不回收孤儿进程的回归测试 同步记录Tauri进程树清理的容器边界 --- .../scripts/start-dev-stack.mjs | 65 +++++++++++++++++-- .../tests/start-dev-stack.test.ts | 32 +++++++++ docs/project-memory/shared-memory/pitfalls.md | 1 + 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 81fae0bee..562e9baaf 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; import http from 'node:http'; import net from 'node:net'; import { resolve } from 'node:path'; @@ -300,16 +300,69 @@ function stopChild(child, signal = 'SIGTERM') { } } -function isProcessGroupAlive(processGroupId, killImpl = process.kill) { +function readLinuxProcessGroupAlive( + processGroupId, + { readdirImpl = readdirSync, readFileImpl = readFileSync } = {}, +) { + let processIds; + try { + processIds = readdirImpl('/proc'); + } catch { + return null; + } + + for (const processId of processIds) { + if (!/^\d+$/.test(processId)) { + continue; + } + let stat; + try { + stat = readFileImpl(`/proc/${processId}/stat`, 'utf8'); + } catch { + continue; + } + const commandEnd = stat.lastIndexOf(') '); + if (commandEnd < 0) { + continue; + } + const [state, , processGroup] = stat + .slice(commandEnd + 2) + .trim() + .split(/\s+/); + if ( + Number(processGroup) === processGroupId && + state !== 'Z' && + state !== 'X' + ) { + return true; + } + } + return false; +} + +function isProcessGroupAlive( + processGroupId, + { + platform = process.platform, + killImpl = process.kill, + readLinuxGroupAlive = readLinuxProcessGroupAlive, + } = {}, +) { if (!Number.isInteger(processGroupId)) { return false; } try { killImpl(-processGroupId, 0); - return true; } catch (error) { return error?.code !== 'ESRCH'; } + if (platform === 'linux') { + const linuxGroupAlive = readLinuxGroupAlive(processGroupId); + if (typeof linuxGroupAlive === 'boolean') { + return linuxGroupAlive; + } + } + return true; } async function waitUntil(check, timeoutMs, pollIntervalMs = 25) { @@ -415,7 +468,7 @@ async function terminateChildTree( stopChild(child, 'SIGTERM'); if ( await waitUntil( - () => !isProcessGroupAlive(processGroupId, killImpl), + () => !isProcessGroupAlive(processGroupId, { platform, killImpl }), gracefulTimeoutMs, ) ) { @@ -430,7 +483,7 @@ async function terminateChildTree( } } const stopped = await waitUntil( - () => !isProcessGroupAlive(processGroupId, killImpl), + () => !isProcessGroupAlive(processGroupId, { platform, killImpl }), forceTimeoutMs, ); return { stopped, forced: true }; @@ -600,8 +653,10 @@ export { ensureBackend, formatChildFailure, isDirectModuleExecution, + isProcessGroupAlive, preflightExistingVite, readChildFailure, + readLinuxProcessGroupAlive, resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts index 50ea09925..07e5d6a4f 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -7,7 +7,9 @@ import { describe, expect, test, vi } from 'vitest'; import { ensureBackend, + isProcessGroupAlive, preflightExistingVite, + readLinuxProcessGroupAlive, resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, @@ -91,6 +93,36 @@ describe('AI 游戏创作配套后端复用门禁', () => { describe('AI 游戏创作启动子进程生命周期', () => { const posixTest = process.platform === 'win32' ? test.skip : test; + test('Linux 进程组只剩僵尸进程时视为已经停止', () => { + const procStats = new Map([ + ['/proc/101/stat', '101 (node worker) Z 1 700 700 0'], + ['/proc/102/stat', '102 (other worker) S 1 701 701 0'], + ]); + const readLinuxGroupAlive = (processGroupId: number) => + readLinuxProcessGroupAlive(processGroupId, { + readdirImpl: () => ['101', '102', 'not-a-pid'], + readFileImpl: (path: string) => { + const stat = procStats.get(path); + if (!stat) { + throw new Error('missing proc stat fixture'); + } + return stat; + }, + }); + const killImpl = vi.fn(); + + expect(readLinuxGroupAlive(700)).toBe(false); + expect(readLinuxGroupAlive(701)).toBe(true); + expect( + isProcessGroupAlive(700, { + platform: 'linux', + killImpl, + readLinuxGroupAlive, + }), + ).toBe(false); + expect(killImpl).toHaveBeenCalledWith(-700, 0); + }); + posixTest('npm 不可解析时进入受控 error 结果而不是未处理事件', async () => { const child = spawnChild('genarrative-command-that-does-not-exist', [], { cwd: process.cwd(), diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 73bac7f44..47794b2b6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4004,6 +4004,7 @@ - 现象:旧 worktree 的 AGC Vite 长期占用 `127.0.0.1:3080`,marker 仍指向旧 API;新 worktree 启动 game-chat 后,配套后端在新端口 ready,随后 `beforeDevCommand` 因代理 target 不匹配返回非零,终端已经回到提示符,但原生客户端和它启动的 Runner 仍存活。客户端 WebView 实际加载旧 Vite,因此当前 master 的界面优化看起来全部缺失。 - 原因:Tauri 的字符串 `beforeDevCommand` 默认 `wait=false`。只要固定 `devUrl` 上已有可访问页面,Tauri CLI 可以在配套启动脚本完成前创建原生窗口;旧实现又直接从 npm 启动 Tauri CLI,没有在 CLI leader 退出后继续持有其 PGID / Windows 进程树。`start-dev-stack.mjs` 虽会在后端 ready 后识别 marker/API 错配,但检查时机已经晚于窗口创建,且只清理自己登记的后端和 Vite。 - 处理:`dev` 与 `game-chat` 统一先进入 `start-tauri-dev.mjs`,在启动 Tauri CLI 前无副作用检查 3080。现有 marker 只有 API target,不能证明监听器属于当前 worktree,因此任何已存在的 3080 都失败关闭,不主动杀不能证明归属的旧服务,也不因 target 看似匹配而复用。Tauri CLI 使用独立 POSIX 进程组,任意退出后按负 PGID 先 TERM、有界等待、再 KILL;Windows 固定调用 `taskkill /PID /T /F`。`start-dev-stack.mjs` 自己的后端 / Vite 独立组也在返回前有界收束。 +- Linux 容器边界:最小化 CI 容器的 PID 1 可能不回收孤儿后代,进程组在所有可执行成员退出后仍只剩 `Z` 僵尸;此时 `kill(-pgid, 0)` 仍成功,不能据此把已经完成的收束误报为失败。Linux 等待逻辑在 signal 探活后必须核对 `/proc//stat`,只把同 PGID 的非 `Z / X` 成员视为存活;`/proc` 不可读时继续使用原保守判断,macOS 等其它 POSIX 平台仍只走 signal 探活。 - 验证:定向测试必须覆盖旧 marker target 在 CLI spawn 前被拒绝、target 看似匹配仍拒绝无归属 Vite、非 HTTP 3080 失败、预检调用顺序、CLI leader 先退出后同 PGID 客户端仍收到 TERM、忽略 TERM 时升级 KILL,以及 Windows taskkill 的 `/PID /T /F` 参数。人工复验旧 worktree 占用 3080 时,新命令不得启动后端或弹出新窗口;正常启动后退出,确认 Tauri 客户端、Runner 和本轮自有后端 / Vite 均按生命周期收束。 - 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`、`apps/ai-game-creator-shell/tests/start-tauri-dev.test.ts`、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts`。