修复 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
+180
View File
@@ -0,0 +1,180 @@
import { describe, expect, test } from 'vitest';
import {
normalizeWindowsPath,
parseWindowsProcessSnapshot,
selectProcessTreeIds,
selectWorktreeOwnedProcessIds,
} from './dev-windows-process.mjs';
function processEntry(overrides) {
return {
ProcessId: 1,
ParentProcessId: 0,
Name: 'node.exe',
ExecutablePath: null,
CommandLine: null,
...overrides,
};
}
describe('Windows 进程快照解析', () => {
test('单个进程对象也归一为数组', () => {
const single = parseWindowsProcessSnapshot(
JSON.stringify({ ProcessId: 42, ParentProcessId: 1 }),
);
expect(single).toHaveLength(1);
expect(single[0].ProcessId).toBe(42);
});
test('空输出和非法 JSON 返回空数组', () => {
expect(parseWindowsProcessSnapshot('')).toEqual([]);
expect(parseWindowsProcessSnapshot('null')).toEqual([]);
expect(parseWindowsProcessSnapshot('not json')).toEqual([]);
});
test('路径归一化去掉 \\\\?\\ 前缀、尾部分隔符并忽略大小写', () => {
expect(
normalizeWindowsPath('\\\\?\\C:\\Repo\\target\\debug\\api-server.exe'),
).toBe('c:\\repo\\target\\debug\\api-server.exe');
expect(normalizeWindowsPath('C:\\Repo\\data\\')).toBe('c:\\repo\\data');
expect(normalizeWindowsPath(null)).toBe('');
});
});
describe('按根 PID 遍历进程树', () => {
test('中间层进程已从快照消失时无法再到达更深的后代', () => {
// cmd(10) -> wrapper(20) -> api-server(30)。Ctrl+C 先杀掉 10 和 20,
// 快照里只剩 30(ParentProcessId 仍指向已消失的 20),父链断开后
// 按根 PID 遍历只能拿到根自己,这正是后端被漏杀的原因。
const snapshot = [
processEntry({
ProcessId: 30,
ParentProcessId: 20,
Name: 'api-server.exe',
}),
];
expect(selectProcessTreeIds(snapshot, 10)).toEqual([10]);
});
test('根已退出但中间层仍在快照里时仍可收全后代', () => {
const snapshot = [
processEntry({ ProcessId: 20, ParentProcessId: 10, Name: 'cargo.exe' }),
processEntry({
ProcessId: 30,
ParentProcessId: 20,
Name: 'api-server.exe',
}),
];
expect(selectProcessTreeIds(snapshot, 10).sort((a, b) => a - b)).toEqual([
10, 20, 30,
]);
});
test('父链完整时能收全后代', () => {
const snapshot = [
processEntry({ ProcessId: 10, ParentProcessId: 1, Name: 'cmd.exe' }),
processEntry({ ProcessId: 20, ParentProcessId: 10, Name: 'cargo.exe' }),
processEntry({
ProcessId: 30,
ParentProcessId: 20,
Name: 'api-server.exe',
}),
processEntry({ ProcessId: 40, ParentProcessId: 1, Name: 'other.exe' }),
];
expect(selectProcessTreeIds(snapshot, 10).sort((a, b) => a - b)).toEqual([
10, 20, 30,
]);
});
});
describe('按身份匹配本工作树后端进程', () => {
const apiServerExePath = 'C:\\Repo\\server-rs\\target\\debug\\api-server.exe';
const spacetimeDataDir =
'C:\\Repo\\server-rs\\.spacetimedb\\ai-game-creator\\data';
test('只收本工作树的 api-server.exe 与同一 data-dir 的 SpacetimeDB', () => {
const snapshot = [
processEntry({
ProcessId: 100,
Name: 'api-server.exe',
ExecutablePath: apiServerExePath,
CommandLine: '"server-rs\\target\\debug\\api-server.exe"',
}),
processEntry({
ProcessId: 101,
Name: 'api-server.exe',
ExecutablePath: 'C:\\Other\\server-rs\\target\\debug\\api-server.exe',
}),
processEntry({
ProcessId: 102,
Name: 'spacetimedb-standalone.exe',
ExecutablePath:
'C:\\Users\\me\\SpacetimeDB\\spacetimedb-standalone.exe',
CommandLine: `spacetimedb-standalone.exe start --data-dir ${spacetimeDataDir} --listen-addr 127.0.0.1:3101`,
}),
processEntry({
ProcessId: 103,
Name: 'spacetimedb-standalone.exe',
CommandLine:
'spacetimedb-standalone.exe start --data-dir C:\\Other\\data --listen-addr 127.0.0.1:3101',
}),
processEntry({
ProcessId: 104,
Name: 'node.exe',
CommandLine: `node dev.mjs --spacetime-data-dir ${spacetimeDataDir}`,
}),
];
expect(
selectWorktreeOwnedProcessIds(snapshot, {
apiServerExePath,
spacetimeDataDir,
}).sort((a, b) => a - b),
).toEqual([100, 102]);
});
test('只给 api-server 路径时不会误伤 SpacetimeDB', () => {
const snapshot = [
processEntry({
ProcessId: 100,
Name: 'api-server.exe',
ExecutablePath: apiServerExePath,
}),
processEntry({
ProcessId: 102,
Name: 'spacetimedb-standalone.exe',
CommandLine: `--data-dir ${spacetimeDataDir}`,
}),
];
expect(
selectWorktreeOwnedProcessIds(snapshot, { apiServerExePath }),
).toEqual([100]);
});
test('排除自身进程且路径大小写不敏感', () => {
const snapshot = [
processEntry({
ProcessId: 200,
Name: 'api-server.exe',
ExecutablePath: apiServerExePath.toUpperCase(),
}),
];
expect(
selectWorktreeOwnedProcessIds(snapshot, {
apiServerExePath,
selfPid: 200,
}),
).toEqual([]);
expect(
selectWorktreeOwnedProcessIds(snapshot, { apiServerExePath }),
).toEqual([200]);
});
test('没有可匹配身份时返回空数组', () => {
const snapshot = [processEntry({ ProcessId: 100, Name: 'api-server.exe' })];
expect(selectWorktreeOwnedProcessIds(snapshot, {})).toEqual([]);
});
});