修复 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>
This commit was merged in pull request #315.
This commit is contained in:
+75
-85
@@ -36,6 +36,13 @@ import {
|
||||
resolveApiServerLogFile,
|
||||
resolveClientHost,
|
||||
} from './dev-utils.mjs';
|
||||
import {
|
||||
readWindowsProcessSnapshot,
|
||||
selectWorktreeOwnedProcessIds,
|
||||
stopWindowsProcessIds,
|
||||
stopWindowsProcessTree as stopWindowsProcessTreeById,
|
||||
stopWindowsWorktreeProcesses,
|
||||
} from './dev-windows-process.mjs';
|
||||
|
||||
// Resolve the workspace from this script's location, not the caller's cwd.
|
||||
// AGC starts this scheduler through `npm --prefix` from its own package; using
|
||||
@@ -917,7 +924,17 @@ class DevService {
|
||||
}
|
||||
|
||||
async function stopProcess(child, label) {
|
||||
if (!child || child.exitCode != null || child.signalCode != null) {
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Windows 下直接子进程是 `cmd.exe /d /s /c` 包装层,Ctrl+C 会先让它退出。
|
||||
// 包装层退出不代表 cargo / api-server / spacetime 已经退出,所以这里不能像
|
||||
// 以前那样直接 return,必须继续按记录下来的根 PID 清理后代。
|
||||
if (child.exitCode != null || child.signalCode != null) {
|
||||
if (process.platform === 'win32' && Number.isInteger(child.pid)) {
|
||||
stopWindowsProcessTree(child.pid, label);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -938,7 +955,7 @@ async function stopProcess(child, label) {
|
||||
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
stopWindowsProcessTree(child.pid);
|
||||
stopWindowsProcessTree(child.pid, label);
|
||||
} else {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
@@ -1135,51 +1152,46 @@ async function stopStaleLocalExternalGenerationWorkers({
|
||||
return stopped;
|
||||
}
|
||||
|
||||
function stopWindowsProcessTree(pid) {
|
||||
if (!pid) {
|
||||
return;
|
||||
function resolveWindowsApiServerExePath(repoRootPath = repoRoot) {
|
||||
return resolve(repoRootPath, 'server-rs/target/debug/api-server.exe');
|
||||
}
|
||||
|
||||
function stopWindowsProcessTree(pid, label = '') {
|
||||
if (!Number.isInteger(pid)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
spawnSync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
[
|
||||
'$ErrorActionPreference = "SilentlyContinue"',
|
||||
'$root = [int]$env:GENARRATIVE_STOP_PID',
|
||||
'$all = Get-CimInstance Win32_Process',
|
||||
'$childrenByParent = @{}',
|
||||
'foreach ($process in $all) {',
|
||||
' $parent = [int]$process.ParentProcessId',
|
||||
' if (-not $childrenByParent.ContainsKey($parent)) { $childrenByParent[$parent] = @() }',
|
||||
' $childrenByParent[$parent] += [int]$process.ProcessId',
|
||||
'}',
|
||||
'$toStop = New-Object System.Collections.Generic.List[int]',
|
||||
'$queue = New-Object System.Collections.Generic.Queue[int]',
|
||||
'$queue.Enqueue($root)',
|
||||
'while ($queue.Count -gt 0) {',
|
||||
' $current = $queue.Dequeue()',
|
||||
' $toStop.Add($current)',
|
||||
' if ($childrenByParent.ContainsKey($current)) {',
|
||||
' foreach ($child in $childrenByParent[$current]) { $queue.Enqueue($child) }',
|
||||
' }',
|
||||
'}',
|
||||
'foreach ($id in ($toStop | Select-Object -Unique | Sort-Object -Descending)) {',
|
||||
' Stop-Process -Id $id -Force -ErrorAction SilentlyContinue',
|
||||
'}',
|
||||
].join('\n'),
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
GENARRATIVE_STOP_PID: String(pid),
|
||||
},
|
||||
stdio: 'ignore',
|
||||
},
|
||||
);
|
||||
const stopped = stopWindowsProcessTreeById(pid);
|
||||
if (stopped.length > 1) {
|
||||
console.log(
|
||||
`[dev${label ? `:${label}` : ''}] 已停止进程树: ${stopped.join(', ')}`,
|
||||
);
|
||||
}
|
||||
return stopped;
|
||||
}
|
||||
|
||||
// 兜底清扫:包装层(cmd.exe / cargo / npm)可能已经退出,父进程链断掉后按 PID
|
||||
// 遍历再也找不到真正的服务进程,因此这里按身份再清一次本工作树的后端。
|
||||
function stopWindowsWorktreeBackendProcesses({
|
||||
spacetimeDataDir = '',
|
||||
logStream = null,
|
||||
snapshot = null,
|
||||
} = {}) {
|
||||
if (process.platform !== 'win32') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stopped = stopWindowsWorktreeProcesses({
|
||||
apiServerExePath: resolveWindowsApiServerExePath(),
|
||||
spacetimeDataDir,
|
||||
snapshot,
|
||||
});
|
||||
if (stopped.length > 0) {
|
||||
const line = `[dev] 已清理本工作树残留后端进程: ${stopped.join(', ')}\n`;
|
||||
process.stdout.write(line);
|
||||
logStream?.write(line);
|
||||
}
|
||||
return stopped;
|
||||
}
|
||||
|
||||
class DevRunner {
|
||||
@@ -2478,6 +2490,14 @@ class DevRunner {
|
||||
await this.services.get(serviceName)?.stop();
|
||||
}
|
||||
|
||||
// 复用别人启动的 SpacetimeDB 时不能连带杀掉对方的 standalone;只有本进程
|
||||
// 自己拉起的 standalone 才属于本次退出的清理范围。
|
||||
stopWindowsWorktreeBackendProcesses({
|
||||
spacetimeDataDir: this.state.spacetimeReused
|
||||
? ''
|
||||
: this.options.spacetimeDataDir,
|
||||
});
|
||||
|
||||
process.exit(code);
|
||||
}
|
||||
}
|
||||
@@ -2487,47 +2507,15 @@ function stopExistingWindowsApiServer(logStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiServerExePath = resolve(
|
||||
repoRoot,
|
||||
'server-rs/target/debug/api-server.exe',
|
||||
);
|
||||
const command = [
|
||||
'$ErrorActionPreference = "Continue"',
|
||||
'$target = [System.IO.Path]::GetFullPath($env:GENARRATIVE_API_SERVER_EXE_TARGET)',
|
||||
'$processes = Get-Process -Name api-server -ErrorAction SilentlyContinue | Where-Object {',
|
||||
' $_.Path -and ([System.IO.Path]::GetFullPath($_.Path) -ieq $target)',
|
||||
'}',
|
||||
'foreach ($process in $processes) {',
|
||||
' try {',
|
||||
' Stop-Process -Id $process.Id -Force -ErrorAction Stop',
|
||||
' Wait-Process -Id $process.Id -Timeout 5 -ErrorAction SilentlyContinue',
|
||||
' Write-Output $process.Id',
|
||||
' } catch {',
|
||||
' Write-Error "[dev:api-server] 忽略旧进程清理瞬时失败 pid=$($process.Id): $($_.Exception.Message)"',
|
||||
' }',
|
||||
'}',
|
||||
'exit 0',
|
||||
].join('\n');
|
||||
const apiServerExePath = resolveWindowsApiServerExePath();
|
||||
const snapshot = readWindowsProcessSnapshot();
|
||||
const processIds = selectWorktreeOwnedProcessIds(snapshot, {
|
||||
apiServerExePath,
|
||||
});
|
||||
const stopped = stopWindowsProcessIds(processIds, { waitForExitMs: 5000 });
|
||||
|
||||
const result = spawnSync(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
GENARRATIVE_API_SERVER_EXE_TARGET: apiServerExePath,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
const output = String(result.stdout ?? '').trim();
|
||||
if (output) {
|
||||
const line = `[dev:api-server] 已停止旧 api-server 进程: ${output}\n`;
|
||||
if (stopped.length > 0) {
|
||||
const line = `[dev:api-server] 已停止旧 api-server 进程: ${stopped.join(', ')}\n`;
|
||||
process.stdout.write(line);
|
||||
logStream?.write(line);
|
||||
}
|
||||
@@ -3505,8 +3493,10 @@ export {
|
||||
resolveDevStackStatePath,
|
||||
resolveLocalSpacetimeApiIdentityPath,
|
||||
resolveLocalSpacetimeRuntimeServiceBootstrapSecretPath,
|
||||
resolveWindowsApiServerExePath,
|
||||
shouldAcceptWatchEvent,
|
||||
shouldTrustExistingSpacetimeToken,
|
||||
stopWindowsWorktreeBackendProcesses,
|
||||
};
|
||||
|
||||
async function main() {
|
||||
|
||||
Reference in New Issue
Block a user