修复 AGC Ctrl+C 残留上个工作树后端导致切换工作树复用旧后端
closes #314 新增 scripts/dev-windows-process.mjs:提供按根 PID 遍历与按身份匹配(api-server.exe 绝对路径、SpacetimeDB --data-dir)两条独立清理路径 scripts/dev.mjs:直接子进程(cmd.exe 包装层)已退出时仍按记录 PID 清理后代,不再提前 return scripts/dev.mjs:退出时按身份兜底清扫本工作树 api-server 与自建 SpacetimeDB,复用他人 standalone 时跳过 scripts/dev.mjs:启动前清理旧 api-server 保留 Wait-Process 等待语义,避免 cargo 报 failed to remove file apps/ai-game-creator-shell/scripts/start-dev-stack.mjs:复用配套后端前校验端口监听进程归属,无法证明归属则改为启动本工作树后端并允许端口漂移 apps/ai-game-creator-shell/scripts/start-dev-stack.mjs:收到信号与 finally 各兜底清扫一次本工作树 api-server.exe,taskkill 失败时降级为按 PID 遍历 apps/ai-game-creator-shell/scripts/start-dev-stack.mjs:等待后端就绪时输出归属校验未通过的具体原因,避免只表现为 600 秒超时 新增 scripts/dev-windows-process.test.ts 并扩充 AGC 复用门禁用例:覆盖断链遍历、身份匹配、归属判定与探测不可用退化 同步 docs/project-memory/shared-memory/pitfalls.md 与本地开发运维文档的进程清理与复用归属口径
This commit is contained in:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user