修复容器进程组清理误判
区分Linux进程组中的可执行成员与僵尸进程 补齐容器PID 1不回收孤儿进程的回归测试 同步记录Tauri进程树清理的容器边界
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 <pid> /T /F`。`start-dev-stack.mjs` 自己的后端 / Vite 独立组也在返回前有界收束。
|
||||
- Linux 容器边界:最小化 CI 容器的 PID 1 可能不回收孤儿后代,进程组在所有可执行成员退出后仍只剩 `Z` 僵尸;此时 `kill(-pgid, 0)` 仍成功,不能据此把已经完成的收束误报为失败。Linux 等待逻辑在 signal 探活后必须核对 `/proc/<pid>/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`。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user