Files
Genarrative/scripts/dev-windows-process.mjs
T
suzmii a1b9b24891
Project CI / Frontend tests (push) Successful in 3m14s
Project CI / Backend tests (push) Successful in 8m54s
Project CI / Repository checks (push) Successful in 2m29s
Project CI / Native shell tests (push) Successful in 19m23s
修复 AGC Ctrl+C 残留上个工作树后端导致切换工作树复用旧后端 (#315)
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>
2026-09-09 20:08:04 +08:00

243 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.execargo 会报 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,
};