修复 AGC Ctrl+C 残留上个工作树后端导致切换工作树复用旧后端 (#315)
Project CI / Repository checks (push) Successful in 2m29s
Project CI / Frontend tests (push) Successful in 3m14s
Project CI / Backend tests (push) Successful in 8m54s
Project CI / Native shell tests (push) Successful in 19m23s

closes #314

## 现象

`npm run agc` 按 Ctrl+C 后有概率残留上个工作树的 `api-server.exe` / SpacetimeDB,切换 worktree 再启动时 AGC 复用旧后端,改过数据库 / schema 的工作树会串库。

## 根因

1. Windows 下长驻服务都经 Node `shell: true` 的 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 先杀包装层(`0xC000013A`);`dev.mjs` 的 `stopProcess` 见到直接子进程已退出就 return,`taskkill /PID <已退出 PID> /T /F` 也只会失败,深处的 `cargo → api-server.exe` 无人清理。
2. 按根 PID 遍历依赖快照里的父子链,中间层先消失时链断,只能拿到根 PID。
3. 复用判据只看 `.app/dev-stack.json` status 与 `/healthz`、`/readyz`、`/v1/ping`,不校验端口上的进程属于哪个工作树,残留后端照样被判为健康并复用。

## 改动

- 新增 `scripts/dev-windows-process.mjs`:按根 PID 遍历 + 按身份匹配(`server-rs/target/debug/api-server.exe` 绝对路径、SpacetimeDB `--data-dir`)两条独立清理路径,带 1s 快照缓存避免清理被拖慢。
- `scripts/dev.mjs`:直接子进程已退出时仍按记录 PID 清理后代;退出时按身份兜底清扫本工作树后端(复用他人 standalone 时跳过);启动前清理旧 api-server 保留 `Wait-Process` 语义,避免 `failed to remove file`。
- `apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`:复用前校验端口监听进程归属,无法证明归属就不复用、改为启动本工作树后端并允许端口漂移;信号与 `finally` 各兜底清扫一次;`taskkill` 失败时降级按 PID 遍历;等待就绪时输出归属校验失败原因,避免静默超时。探测不可用时退化为旧行为,不阻断本地启动。
- 测试与文档:新增 `scripts/dev-windows-process.test.ts`、扩充 AGC 复用门禁用例;同步 `docs/project-memory/shared-memory/pitfalls.md` 与本地开发运维文档。

## 验证

- 伪造 `api-server.exe` 进程:按身份精确命中并杀掉(`matched=[17284] stopped=[17284]`)。
- 3 个真实监听进程下归属判定:`owned` / `api-server-owner-mismatch` / `spacetime-owner-mismatch` 均正确。
- `npx vitest run scripts/dev.test.ts scripts/dev-windows-process.test.ts scripts/dev-stack-port-utils.test.ts apps/ai-game-creator-shell/tests/...`:119 passed(唯一失败为 Windows 文件权限用例,已确认在合并基线 `origin/master` 上同样失败)。
- `node --check`、`eslint --max-warnings 0`、`prettier --check`、`npm run check:encoding`、`git diff --check` 全部通过。

## 备注

Rust 侧 `api-server` 的 `with_graceful_shutdown` 没有超时上限,是「有概率」的来源之一;本次只在 Node 侧收口,是否给优雅退出加 deadline 可另行评估。

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/315
Co-authored-by: Suzumiya <suzmii@qq.com>
Co-committed-by: Suzumiya <suzmii@qq.com>
This commit was merged in pull request #315.
This commit is contained in:
2026-09-09 20:08:04 +08:00
committed by 孔令弘
parent 7c49e7e51c
commit a1b9b24891
7 changed files with 992 additions and 103 deletions
+242
View File
@@ -0,0 +1,242 @@
import { spawnSync } from 'node:child_process';
// Windows 开发栈清理工具。
//
// 背景:Windows 下所有长驻服务都经 `cmd.exe /d /s /c` 包装层启动(Node 的
// `shell: true`),而 Ctrl+C 会先让包装层退出。一旦中间层退出,按父进程链
// 遍历就再也到不了更深的服务进程,`taskkill /T` 也会因为 PID 已消失而失效。
// 因此这里同时提供两种定位方式:
// 1. `selectProcessTreeIds`:按记录下来的根 PID 做父子链遍历(能处理根已退出、
// 但中间层仍留在快照里的情况)。
// 2. `selectWorktreeOwnedProcessIds`:按身份匹配(api-server.exe 的绝对路径、
// SpacetimeDB 的 --data-dir),不依赖任何仍然存活的包装层。
// 两者结合后,即使 `npm run agc` 的 Ctrl+C 只杀掉了 shell 包装层,也不会留下
// 属于本工作树的后端进程。
function normalizeWindowsPath(value) {
const raw = String(value ?? '')
.trim()
.replace(/^\\\\\?\\/u, '');
if (!raw) {
return '';
}
return raw.replace(/[\\/]+$/u, '').toLowerCase();
}
function parseWindowsProcessSnapshot(rawText) {
const raw = String(rawText ?? '').trim();
if (!raw) {
return [];
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return [];
}
if (!parsed) {
return [];
}
return Array.isArray(parsed) ? parsed : [parsed];
}
// 一次退出流程里会多次清理(每个服务的进程树 + 最后的身份兜底清扫),
// PowerShell 全量进程快照约 1 秒,短时间内复用同一份快照即可,避免 Ctrl+C
// 后清理被拖成十几秒。只在默认实现下缓存,注入实现(测试)始终重新读取。
const PROCESS_SNAPSHOT_TTL_MS = 1000;
let cachedProcessSnapshot = null;
let cachedProcessSnapshotAt = 0;
function readWindowsProcessSnapshot({
spawnSyncImpl = spawnSync,
env = process.env,
now = Date.now,
ttlMs = PROCESS_SNAPSHOT_TTL_MS,
} = {}) {
const cacheable = spawnSyncImpl === spawnSync && env === process.env;
if (
cacheable &&
cachedProcessSnapshot &&
now() - cachedProcessSnapshotAt < ttlMs
) {
return cachedProcessSnapshot;
}
const command = [
'$ErrorActionPreference = "SilentlyContinue"',
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine | ConvertTo-Json -Compress',
].join('\n');
const result = spawnSyncImpl(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command],
{
encoding: 'utf8',
env,
maxBuffer: 32 * 1024 * 1024,
},
);
if (result?.error || result?.status !== 0) {
return [];
}
const snapshot = parseWindowsProcessSnapshot(result.stdout);
if (cacheable) {
cachedProcessSnapshot = snapshot;
cachedProcessSnapshotAt = now();
}
return snapshot;
}
function selectProcessTreeIds(processes, rootPid) {
if (!Number.isInteger(rootPid)) {
return [];
}
const childrenByParent = new Map();
for (const processEntry of processes ?? []) {
const parentId = Number(processEntry?.ParentProcessId);
const processId = Number(processEntry?.ProcessId);
if (!Number.isInteger(parentId) || !Number.isInteger(processId)) {
continue;
}
if (!childrenByParent.has(parentId)) {
childrenByParent.set(parentId, []);
}
childrenByParent.get(parentId).push(processId);
}
const collected = new Set();
const queue = [rootPid];
while (queue.length > 0) {
const current = queue.shift();
if (collected.has(current)) {
continue;
}
collected.add(current);
for (const childId of childrenByParent.get(current) ?? []) {
queue.push(childId);
}
}
return [...collected];
}
function selectWorktreeOwnedProcessIds(
processes,
{ apiServerExePath = '', spacetimeDataDir = '', selfPid = process.pid } = {},
) {
const expectedExePath = normalizeWindowsPath(apiServerExePath);
const expectedDataDir = normalizeWindowsPath(spacetimeDataDir);
if (!expectedExePath && !expectedDataDir) {
return [];
}
const matched = [];
for (const processEntry of processes ?? []) {
const processId = Number(processEntry?.ProcessId);
if (!Number.isInteger(processId) || processId === selfPid) {
continue;
}
const executablePath = normalizeWindowsPath(processEntry?.ExecutablePath);
if (expectedExePath && executablePath === expectedExePath) {
matched.push(processId);
continue;
}
if (!expectedDataDir) {
continue;
}
const name = String(processEntry?.Name ?? '').toLowerCase();
if (!name.startsWith('spacetime')) {
continue;
}
if (
normalizeWindowsPath(processEntry?.CommandLine).includes(expectedDataDir)
) {
matched.push(processId);
}
}
return matched;
}
function stopWindowsProcessIds(
processIds,
{ spawnSyncImpl = spawnSync, env = process.env, waitForExitMs = 0 } = {},
) {
const uniqueIds = [
...new Set((processIds ?? []).filter((value) => Number.isInteger(value))),
];
if (uniqueIds.length === 0) {
return [];
}
const command = [
'$ErrorActionPreference = "SilentlyContinue"',
'$ids = $env:GENARRATIVE_STOP_PIDS -split ","',
'foreach ($id in $ids) {',
' if ($id) { Stop-Process -Id ([int]$id) -Force -ErrorAction SilentlyContinue }',
'}',
...(waitForExitMs > 0
? [
// 启动前清理旧 api-server 时必须等它真正退出,否则 Windows 仍占用
// target\debug\api-server.exe,cargo 会报 failed to remove file。
`Wait-Process -Id $ids -Timeout ${Math.ceil(waitForExitMs / 1000)} -ErrorAction SilentlyContinue`,
]
: []),
].join('\n');
spawnSyncImpl(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command],
{
env: { ...env, GENARRATIVE_STOP_PIDS: uniqueIds.join(',') },
stdio: 'ignore',
},
);
return uniqueIds;
}
function stopWindowsProcessTree(
rootPid,
{ snapshot = null, spawnSyncImpl = spawnSync, env = process.env } = {},
) {
if (!Number.isInteger(rootPid)) {
return [];
}
const processes =
snapshot ?? readWindowsProcessSnapshot({ spawnSyncImpl, env });
return stopWindowsProcessIds(selectProcessTreeIds(processes, rootPid), {
spawnSyncImpl,
env,
});
}
function stopWindowsWorktreeProcesses({
apiServerExePath = '',
spacetimeDataDir = '',
snapshot = null,
spawnSyncImpl = spawnSync,
env = process.env,
} = {}) {
const processes =
snapshot ?? readWindowsProcessSnapshot({ spawnSyncImpl, env });
return stopWindowsProcessIds(
selectWorktreeOwnedProcessIds(processes, {
apiServerExePath,
spacetimeDataDir,
}),
{ spawnSyncImpl, env },
);
}
export {
normalizeWindowsPath,
parseWindowsProcessSnapshot,
readWindowsProcessSnapshot,
selectProcessTreeIds,
selectWorktreeOwnedProcessIds,
stopWindowsProcessIds,
stopWindowsProcessTree,
stopWindowsWorktreeProcesses,
};