修复 AGC Ctrl+C 残留上个工作树后端导致切换工作树复用旧后端 (#315)
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

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
@@ -1,10 +1,16 @@
import { spawn } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { existsSync, readdirSync, readFileSync } from 'node:fs';
import http from 'node:http'; import http from 'node:http';
import net from 'node:net'; import net from 'node:net';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import {
normalizeWindowsPath,
parseWindowsProcessSnapshot,
stopWindowsProcessTree,
stopWindowsWorktreeProcesses,
} from '../../../scripts/dev-windows-process.mjs';
import { import {
agcVitePortEnvKey, agcVitePortEnvKey,
readAgcDevEndpoint, readAgcDevEndpoint,
@@ -15,6 +21,10 @@ import {
const appRoot = fileURLToPath(new URL('..', import.meta.url)); const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = resolve(appRoot, '../..'); const repoRoot = resolve(appRoot, '../..');
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json'); const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
const apiServerExePath = resolve(
repoRoot,
'server-rs/target/debug/api-server.exe',
);
const defaultApiTarget = const defaultApiTarget =
process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082'; process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082';
const backendDatabase = 'genarrative-game-creator-dev'; const backendDatabase = 'genarrative-game-creator-dev';
@@ -160,19 +170,184 @@ function readBackendServiceFailure(
return null; return null;
} }
function urlPort(url) {
try {
const port = Number(new URL(url).port);
return Number.isInteger(port) && port > 0 ? port : 0;
} catch {
return 0;
}
}
// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如缺少
// Get-NetTCPConnection),此时调用方必须退化为旧行为,不能让本地启动直接失败。
function readWindowsPortOwnerIdentities(
ports,
{ spawnImpl = spawnSync, env = process.env } = {},
) {
const uniquePorts = [...new Set(ports.filter((port) => port > 0))];
if (uniquePorts.length === 0) {
return null;
}
const command = [
'$ErrorActionPreference = "SilentlyContinue"',
'$ports = ($env:GENARRATIVE_QUERY_PORTS -split ",") | Where-Object { $_ }',
'$result = @()',
'foreach ($port in $ports) {',
' $connection = Get-NetTCPConnection -State Listen -LocalPort ([int]$port) -ErrorAction SilentlyContinue | Select-Object -First 1',
' if (-not $connection) { continue }',
' $owner = Get-CimInstance Win32_Process -Filter ("ProcessId=" + $connection.OwningProcess) -ErrorAction SilentlyContinue',
' $result += [pscustomobject]@{ port = [int]$port; processId = [int]$connection.OwningProcess; name = $owner.Name; executablePath = $owner.ExecutablePath; commandLine = $owner.CommandLine }',
'}',
'ConvertTo-Json -InputObject @($result) -Compress',
].join('\n');
const result = spawnImpl(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command],
{
encoding: 'utf8',
env: { ...env, GENARRATIVE_QUERY_PORTS: uniquePorts.join(',') },
maxBuffer: 8 * 1024 * 1024,
},
);
if (result?.error || result?.status !== 0) {
return null;
}
const owners = new Map();
for (const entry of parseWindowsProcessSnapshot(result.stdout)) {
const port = Number(entry?.port);
if (Number.isInteger(port) && port > 0) {
owners.set(port, entry);
}
}
return owners;
}
function isWorktreeApiServerOwner(
owner,
{ expectedExePath = apiServerExePath } = {},
) {
if (!owner) {
return false;
}
const expected = normalizeWindowsPath(expectedExePath);
const actual = normalizeWindowsPath(owner.executablePath);
return Boolean(expected) && actual === expected;
}
function isWorktreeSpacetimeOwner(
owner,
{ expectedDataDir = backendSpacetimeDataDir } = {},
) {
if (!owner) {
return false;
}
const expected = normalizeWindowsPath(expectedDataDir);
if (!expected) {
return false;
}
const name = String(owner.name ?? '').toLowerCase();
if (!name.startsWith('spacetime')) {
return false;
}
return normalizeWindowsPath(owner.commandLine).includes(expected);
}
// 端口健康不代表后端属于当前工作树:上个工作树 Ctrl+C 残留的 api-server 仍会
// 应答 /healthz。复用前必须证明端口上的进程就是本工作树的可执行文件与数据目录。
function verifyAgcBackendOwnership({
apiUrl,
spacetimeUrl,
bgfilterWorkerUrl,
platform = process.platform,
expectedExePath = apiServerExePath,
expectedDataDir = backendSpacetimeDataDir,
readPortOwners = readWindowsPortOwnerIdentities,
} = {}) {
if (platform !== 'win32') {
return { ok: true, reason: 'platform-unsupported', owners: new Map() };
}
const ports = [
urlPort(apiUrl),
urlPort(bgfilterWorkerUrl),
urlPort(spacetimeUrl),
];
const owners = readPortOwners(ports);
if (!owners) {
return { ok: true, reason: 'owner-probe-unavailable', owners: new Map() };
}
const apiOwner = owners.get(urlPort(apiUrl));
if (!isWorktreeApiServerOwner(apiOwner, { expectedExePath })) {
return { ok: false, reason: 'api-server-owner-mismatch', owners, apiOwner };
}
const workerOwner = owners.get(urlPort(bgfilterWorkerUrl));
if (!isWorktreeApiServerOwner(workerOwner, { expectedExePath })) {
return {
ok: false,
reason: 'bgfilter-worker-owner-mismatch',
owners,
workerOwner,
};
}
const spacetimeOwner = owners.get(urlPort(spacetimeUrl));
if (!isWorktreeSpacetimeOwner(spacetimeOwner, { expectedDataDir })) {
return {
ok: false,
reason: 'spacetime-owner-mismatch',
owners,
spacetimeOwner,
};
}
return { ok: true, reason: 'owned', owners };
}
function formatOwnerLabel(owner) {
if (!owner) {
return '未知进程';
}
const pid = Number(owner.processId);
const label = owner.executablePath || owner.commandLine || owner.name || '';
return `${Number.isInteger(pid) ? `pid=${pid} ` : ''}${String(label).trim()}`.trim();
}
async function isBackendReady({ async function isBackendReady({
state = readJson(devStackStatePath), state = readJson(devStackStatePath),
isReady = isHttpReady, isReady = isHttpReady,
verifyOwnership = verifyAgcBackendOwnership,
onOwnershipRejected = null,
} = {}) { } = {}) {
const { apiUrl, spacetimeUrl, bgfilterWorkerUrl, hasMatchingBackend } = const { apiUrl, spacetimeUrl, bgfilterWorkerUrl, hasMatchingBackend } =
resolveBackendTargetsFromState(state, { resolveBackendTargetsFromState(state, {
requireAgcBackend: true, requireAgcBackend: true,
}); });
if (!hasMatchingBackend || !apiUrl || !spacetimeUrl || !bgfilterWorkerUrl) {
return false;
}
const ownership = await verifyOwnership({
apiUrl,
spacetimeUrl,
bgfilterWorkerUrl,
});
if (!ownership?.ok) {
onOwnershipRejected?.(ownership);
return false;
}
if (ownership.reason === 'owner-probe-unavailable') {
console.warn(
'[ai-game-creator-shell] 无法读取端口监听进程归属,本次按旧行为复用配套后端。',
);
}
return ( return (
hasMatchingBackend &&
Boolean(apiUrl) &&
Boolean(spacetimeUrl) &&
Boolean(bgfilterWorkerUrl) &&
(await isReady(`${apiUrl}/healthz`)) && (await isReady(`${apiUrl}/healthz`)) &&
(await isReady(`${spacetimeUrl}/v1/ping`)) && (await isReady(`${spacetimeUrl}/v1/ping`)) &&
(await isReady(`${bgfilterWorkerUrl}/readyz`)) (await isReady(`${bgfilterWorkerUrl}/readyz`))
@@ -485,14 +660,18 @@ async function terminateChildTree(
return { stopped: true, forced: false }; return { stopped: true, forced: false };
} }
const result = await taskkillImpl(child.pid); const result = await taskkillImpl(child.pid);
return { const taskkillStopped =
stopped: !result?.timedOut &&
!result?.timedOut && !result?.error &&
!result?.error && [0, 128].includes(result?.code ?? 0);
[0, 128].includes(result?.code ?? 0), if (taskkillStopped) {
forced: true, return { stopped: true, forced: true, result };
result, }
};
// 包装层(cmd.exe / npm.cmd)先被 Ctrl+C 杀掉时 taskkill 拿不到活着的 PID
// 这里继续按记录下来的根 PID 遍历,尽量收掉更深的后端进程。
const treeStopped = stopWindowsProcessTree(child.pid);
return { stopped: treeStopped.length > 0, forced: true, result };
} }
const processGroupId = childLifecycles.get(child)?.processGroupId; const processGroupId = childLifecycles.get(child)?.processGroupId;
@@ -542,15 +721,29 @@ async function waitForBackendReady(
backendChild, backendChild,
timeoutMs = 600_000, timeoutMs = 600_000,
{ {
checkBackendReady = isBackendReady, checkBackendReady = (onOwnershipRejected) =>
isBackendReady({ onOwnershipRejected }),
readState = () => readJson(devStackStatePath), readState = () => readJson(devStackStatePath),
resolveTargets = readBackendTargets, resolveTargets = readBackendTargets,
} = {}, } = {},
) { ) {
const initialStateUpdatedAt = readState()?.updatedAt ?? ''; const initialStateUpdatedAt = readState()?.updatedAt ?? '';
const startedAt = Date.now(); const startedAt = Date.now();
let lastOwnershipReason = '';
while (Date.now() - startedAt < timeoutMs) { while (Date.now() - startedAt < timeoutMs) {
if (await checkBackendReady()) { if (
await checkBackendReady((ownership) => {
if (ownership.reason === lastOwnershipReason) {
return;
}
lastOwnershipReason = ownership.reason;
// 本次自己拉起的后端如果归属校验一直不通过,必须把原因打出来,
// 否则只会表现为等待 600 秒后超时。
console.warn(
`[ai-game-creator-shell] 等待配套后端就绪时归属校验未通过(${ownership.reason}: ${formatOwnerLabel(ownership.apiOwner ?? ownership.spacetimeOwner ?? ownership.workerOwner)})。`,
);
})
) {
return resolveTargets(); return resolveTargets();
} }
const state = readState(); const state = readState();
@@ -576,7 +769,14 @@ async function waitForBackendReady(
async function ensureBackend({ async function ensureBackend({
onBackendChild = () => {}, onBackendChild = () => {},
checkBackendReady = isBackendReady, checkBackendReady = () =>
isBackendReady({
onOwnershipRejected(ownership) {
console.warn(
`[ai-game-creator-shell] 端口上的配套后端不属于当前工作树(${ownership.reason}: ${formatOwnerLabel(ownership.apiOwner ?? ownership.spacetimeOwner ?? ownership.workerOwner)}),改为启动本工作树自己的后端。`,
);
},
}),
resolveTargets = readBackendTargets, resolveTargets = readBackendTargets,
spawnBackend = () => spawnBackend = () =>
spawnChild( spawnChild(
@@ -656,15 +856,33 @@ async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) {
async function main() { async function main() {
let backendChild = null; let backendChild = null;
let startedBackend = false;
let viteChild = null; let viteChild = null;
let shutdownSignal = ''; let shutdownSignal = '';
const signalHandlers = new Map(); const signalHandlers = new Map();
// 只有本次会话真正拉起过配套后端时才做兜底清扫:复用别人后端时不能连带
// 杀掉对方的进程。dev.mjs 的清理依赖它的 shell 包装层仍然活着,而 Ctrl+C
// 往往先杀掉包装层,所以这里必须按本工作树 api-server.exe 的身份再收一次。
const sweepStartedBackend = () => {
if (!startedBackend || process.platform !== 'win32') {
return;
}
const stopped = stopWindowsWorktreeProcesses({ apiServerExePath });
if (stopped.length > 0) {
console.log(
`[ai-game-creator-shell] 已清理残留后端进程: ${stopped.join(', ')}`,
);
}
};
for (const signal of ['SIGINT', 'SIGTERM']) { for (const signal of ['SIGINT', 'SIGTERM']) {
const handler = () => { const handler = () => {
shutdownSignal = signal; shutdownSignal = signal;
stopChild(viteChild, signal); stopChild(viteChild, signal);
stopChild(backendChild, signal); stopChild(backendChild, signal);
// 立刻清扫,避免外层 taskkill /F 抢在 finally 之前把本进程杀掉。
sweepStartedBackend();
}; };
signalHandlers.set(signal, handler); signalHandlers.set(signal, handler);
process.on(signal, handler); process.on(signal, handler);
@@ -683,6 +901,7 @@ async function main() {
}, },
}); });
backendChild = backend.backendChild; backendChild = backend.backendChild;
startedBackend = Boolean(backendChild);
if (shutdownSignal) { if (shutdownSignal) {
throw new Error(`启动期收到 ${shutdownSignal},已停止配套后端`); throw new Error(`启动期收到 ${shutdownSignal},已停止配套后端`);
} }
@@ -716,6 +935,7 @@ async function main() {
terminateChildTree(viteChild), terminateChildTree(viteChild),
terminateChildTree(backendChild), terminateChildTree(backendChild),
]); ]);
sweepStartedBackend();
for (const [signal, handler] of signalHandlers) { for (const [signal, handler] of signalHandlers) {
process.off(signal, handler); process.off(signal, handler);
} }
@@ -732,20 +952,25 @@ function isDirectModuleExecution() {
export { export {
ensureBackend, ensureBackend,
formatChildFailure, formatChildFailure,
formatOwnerLabel,
isAiGameCreatorServer, isAiGameCreatorServer,
isBackendReady, isBackendReady,
isDirectModuleExecution, isDirectModuleExecution,
isProcessGroupAlive, isProcessGroupAlive,
isWorktreeApiServerOwner,
isWorktreeSpacetimeOwner,
preflightExistingVite, preflightExistingVite,
readBackendServiceFailure, readBackendServiceFailure,
readChildFailure, readChildFailure,
readExistingViteServer, readExistingViteServer,
readLinuxProcessGroupAlive, readLinuxProcessGroupAlive,
readWindowsPortOwnerIdentities,
resolveBackendTargetsFromState, resolveBackendTargetsFromState,
runWindowsTaskkill, runWindowsTaskkill,
spawnChild, spawnChild,
stopChild, stopChild,
terminateChildTree, terminateChildTree,
verifyAgcBackendOwnership,
waitForBackendReady, waitForBackendReady,
waitForChildTermination, waitForChildTermination,
}; };
@@ -7,22 +7,33 @@ import { describe, expect, test, vi } from 'vitest';
import { import {
ensureBackend, ensureBackend,
formatOwnerLabel,
isBackendReady, isBackendReady,
isProcessGroupAlive, isProcessGroupAlive,
isWorktreeApiServerOwner,
isWorktreeSpacetimeOwner,
preflightExistingVite, preflightExistingVite,
readBackendServiceFailure, readBackendServiceFailure,
readLinuxProcessGroupAlive, readLinuxProcessGroupAlive,
readWindowsPortOwnerIdentities,
resolveBackendTargetsFromState, resolveBackendTargetsFromState,
runWindowsTaskkill, runWindowsTaskkill,
spawnChild, spawnChild,
stopChild, stopChild,
terminateChildTree, terminateChildTree,
verifyAgcBackendOwnership,
waitForBackendReady, waitForBackendReady,
waitForChildTermination, waitForChildTermination,
} from '../scripts/start-dev-stack.mjs'; } from '../scripts/start-dev-stack.mjs';
const expectedDatabase = 'genarrative-game-creator-dev'; const expectedDatabase = 'genarrative-game-creator-dev';
const expectedDataDir = resolve('server-rs/.spacetimedb/ai-game-creator/data'); const expectedDataDir = resolve('server-rs/.spacetimedb/ai-game-creator/data');
const expectedExePath = resolve('server-rs/target/debug/api-server.exe');
const ownedBackend = async () => ({
ok: true,
reason: 'owned',
owners: new Map(),
});
function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) { function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) {
return { return {
@@ -109,6 +120,7 @@ describe('AI 游戏创作配套后端复用门禁', () => {
isBackendReady({ isBackendReady({
state: backendState(expectedDataDir, false), state: backendState(expectedDataDir, false),
isReady, isReady,
verifyOwnership: ownedBackend,
}), }),
).resolves.toBe(false); ).resolves.toBe(false);
expect(isReady).not.toHaveBeenCalled(); expect(isReady).not.toHaveBeenCalled();
@@ -118,6 +130,7 @@ describe('AI 游戏创作配套后端复用门禁', () => {
isBackendReady({ isBackendReady({
state: backendState(expectedDataDir), state: backendState(expectedDataDir),
isReady, isReady,
verifyOwnership: ownedBackend,
}), }),
).resolves.toBe(false); ).resolves.toBe(false);
expect(isReady).toHaveBeenCalledWith('http://127.0.0.1:8082/healthz'); expect(isReady).toHaveBeenCalledWith('http://127.0.0.1:8082/healthz');
@@ -127,6 +140,7 @@ describe('AI 游戏创作配套后端复用门禁', () => {
isBackendReady({ isBackendReady({
state: backendState(expectedDataDir), state: backendState(expectedDataDir),
isReady, isReady,
verifyOwnership: ownedBackend,
}), }),
).resolves.toBe(true); ).resolves.toBe(true);
}); });
@@ -171,6 +185,232 @@ describe('AI 游戏创作配套后端复用门禁', () => {
}), }),
).rejects.toThrow('配套后端启动失败: bgfilter-worker code=98'); ).rejects.toThrow('配套后端启动失败: bgfilter-worker code=98');
}); });
test('端口上的后端不属于当前工作树时拒绝复用', async () => {
const isReady = vi.fn(async () => true);
const onOwnershipRejected = vi.fn();
await expect(
isBackendReady({
state: backendState(expectedDataDir),
isReady,
verifyOwnership: async () => ({
ok: false,
reason: 'api-server-owner-mismatch',
apiOwner: { processId: 4321, name: 'api-server.exe' },
}),
onOwnershipRejected,
}),
).resolves.toBe(false);
expect(onOwnershipRejected).toHaveBeenCalledWith(
expect.objectContaining({ reason: 'api-server-owner-mismatch' }),
);
expect(isReady).not.toHaveBeenCalled();
});
test('归属探测不可用时退化为旧行为而不是让本地启动失败', async () => {
const isReady = vi.fn(async () => true);
await expect(
isBackendReady({
state: backendState(expectedDataDir),
isReady,
verifyOwnership: async () => ({
ok: true,
reason: 'owner-probe-unavailable',
owners: new Map(),
}),
}),
).resolves.toBe(true);
expect(isReady).toHaveBeenCalledWith('http://127.0.0.1:8082/healthz');
});
});
describe('AI 游戏创作配套后端归属校验', () => {
const urls = {
apiUrl: 'http://127.0.0.1:8082',
spacetimeUrl: 'http://127.0.0.1:3101',
bgfilterWorkerUrl: 'http://127.0.0.1:8083',
};
function ownerMap({
apiExe = expectedExePath,
dataDir = expectedDataDir,
} = {}) {
return new Map([
[
8082,
{
port: 8082,
processId: 11,
name: 'api-server.exe',
executablePath: apiExe,
commandLine: null,
},
],
[
8083,
{
port: 8083,
processId: 12,
name: 'api-server.exe',
executablePath: expectedExePath,
commandLine: null,
},
],
[
3101,
{
port: 3101,
processId: 13,
name: 'spacetimedb-standalone.exe',
executablePath: null,
commandLine: `spacetimedb-standalone.exe start --data-dir ${dataDir}`,
},
],
]);
}
test('api-server 可执行文件来自其它工作树时判定为不归属', () => {
const result = verifyAgcBackendOwnership({
...urls,
platform: 'win32',
expectedExePath,
expectedDataDir,
readPortOwners: () =>
ownerMap({
apiExe: resolve(
'.worktrees/other/server-rs/target/debug/api-server.exe',
),
}),
});
expect(result.ok).toBe(false);
expect(result.reason).toBe('api-server-owner-mismatch');
});
test('SpacetimeDB 使用其它 data dir 时判定为不归属', () => {
const result = verifyAgcBackendOwnership({
...urls,
platform: 'win32',
expectedExePath,
expectedDataDir,
readPortOwners: () =>
ownerMap({ dataDir: resolve('server-rs/.spacetimedb/local/data') }),
});
expect(result.ok).toBe(false);
expect(result.reason).toBe('spacetime-owner-mismatch');
});
test('可执行文件与 data dir 都匹配时允许复用', () => {
const result = verifyAgcBackendOwnership({
...urls,
platform: 'win32',
expectedExePath,
expectedDataDir,
readPortOwners: () => ownerMap(),
});
expect(result).toMatchObject({ ok: true, reason: 'owned' });
});
test('归属探测不可用时不阻断本地启动', () => {
const result = verifyAgcBackendOwnership({
...urls,
platform: 'win32',
expectedExePath,
expectedDataDir,
readPortOwners: () => null,
});
expect(result).toMatchObject({
ok: true,
reason: 'owner-probe-unavailable',
});
});
test('非 Windows 平台保持原有复用行为', () => {
const result = verifyAgcBackendOwnership({ ...urls, platform: 'linux' });
expect(result).toMatchObject({ ok: true, reason: 'platform-unsupported' });
});
test('可执行文件路径与 data dir 归属判定忽略大小写和 \\\\?\\ 前缀', () => {
expect(
isWorktreeApiServerOwner(
{
processId: 1,
executablePath: `\\\\?\\${expectedExePath.toUpperCase()}`,
},
{ expectedExePath },
),
).toBe(true);
expect(
isWorktreeSpacetimeOwner(
{
processId: 2,
name: 'spacetimedb-standalone.exe',
commandLine: `start --data-dir ${expectedDataDir.toUpperCase()}`,
},
{ expectedDataDir },
),
).toBe(true);
expect(
isWorktreeSpacetimeOwner(
{ processId: 3, name: 'node.exe', commandLine: expectedDataDir },
{ expectedDataDir },
),
).toBe(false);
});
test('端口监听进程探测解析 PowerShell 输出', () => {
const spawnImpl = vi.fn(() => ({
status: 0,
error: null,
stdout: JSON.stringify([
{
port: 8082,
processId: 4321,
name: 'api-server.exe',
executablePath: expectedExePath,
commandLine: null,
},
]),
}));
const owners = readWindowsPortOwnerIdentities([8082, 0], {
spawnImpl,
env: {},
});
expect(owners?.get(8082)).toMatchObject({ processId: 4321 });
expect(spawnImpl).toHaveBeenCalledWith(
'powershell.exe',
expect.any(Array),
expect.objectContaining({ env: { GENARRATIVE_QUERY_PORTS: '8082' } }),
);
});
test('探测失败时返回 null 以触发退化分支', () => {
expect(
readWindowsPortOwnerIdentities([8082], {
spawnImpl: () => ({ status: 1, error: null, stdout: '' }),
env: {},
}),
).toBeNull();
expect(readWindowsPortOwnerIdentities([], { env: {} })).toBeNull();
});
test('归属日志包含 pid 与进程标识', () => {
expect(
formatOwnerLabel({
processId: 4321,
executablePath: 'C:\\a\\api-server.exe',
}),
).toBe('pid=4321 C:\\a\\api-server.exe');
expect(formatOwnerLabel(null)).toBe('未知进程');
});
}); });
describe('AI 游戏创作启动子进程生命周期', () => { describe('AI 游戏创作启动子进程生命周期', () => {
@@ -34,6 +34,18 @@
主配置与 local overlay 的单文件原子写入不能保证整体成功;覆盖层写入失败会留下混合配置。保存前先序列化全部变更,多文件保存保留原内容,错误时逆序恢复并报告回滚失败;单文件保持原写入路径,成功后不回读、不触发外部诊断。此回滚仅处理可捕获错误,不承诺进程崩溃下的事务恢复。 主配置与 local overlay 的单文件原子写入不能保证整体成功;覆盖层写入失败会留下混合配置。保存前先序列化全部变更,多文件保存保留原内容,错误时逆序恢复并报告回滚失败;单文件保持原写入路径,成功后不回读、不触发外部诊断。此回滚仅处理可捕获错误,不承诺进程崩溃下的事务恢复。
## 2026-09-09 `npm run agc` 的 Ctrl+C 不能只依赖 shell 包装层与端口健康检查
- **现象**`npm run agc` 按 Ctrl+C 后终端回到提示符,但上个工作树的 `api-server.exe` / SpacetimeDB 仍在监听 `8082` / `8083` / `3101`;切到另一个 worktree 再启动 AGC 时,前端仍然连到上个工作树的后端,在改过数据库 / schema 的工作树上会串库。
- **原因**
1. Windows 下所有长驻服务都由 Node `shell: true``cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`)。`scripts/dev.mjs``stopProcess` 见到直接子进程已退出就直接 `return``start-dev-stack.mjs` / `start-tauri-dev.mjs` 对已退出 PID 的 `taskkill /PID <pid> /T /F` 只会失败并返回 `stopped: false`,于是更深的 `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`)两条独立清理路径。`dev.mjs` 在直接子进程已退出时也继续清理,并在退出时按身份兜底清扫本工作树后端(复用他人 standalone 时不清理)。`start-dev-stack.mjs` 在收到信号和 `finally` 各清扫一次本工作树 `api-server.exe`(仅限本次自己拉起后端的情况),复用前先校验端口监听进程归属,无法证明归属就不复用、改为启动自己的后端并允许端口漂移。
- **排查顺序**:先看 `.app/dev-stack.json` 的 status 与实际监听端口是否一致,再用 `Get-CimInstance Win32_Process` 按本工作树 `server-rs\target\debug\api-server.exe` 路径与 SpacetimeDB `--data-dir` 核对残留进程;不要因为 `/healthz` 返回 200 就认定后端属于当前工作树。
- **验证**`node --check scripts/dev.mjs scripts/dev-windows-process.mjs apps/ai-game-creator-shell/scripts/start-dev-stack.mjs``npx vitest run scripts/dev-windows-process.test.ts apps/ai-game-creator-shell/tests/start-dev-stack.test.ts scripts/dev.test.ts`;真机确认 Ctrl+C 后没有匹配本工作树 `api-server.exe` 路径的残留进程。
- **关联**`scripts/dev.mjs``scripts/dev-windows-process.mjs``apps/ai-game-creator-shell/scripts/start-dev-stack.mjs``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-09-02 Tauri 事件桥在浏览器预览中必须 fail-safe ## 2026-09-02 Tauri 事件桥在浏览器预览中必须 fail-safe
- **现象**Vitest/jsdom 挂载 AGC 客户端时,错误报告通知调用 `@tauri-apps/api/event.listen`,因缺少 `window.__TAURI_INTERNALS__` 产生未处理拒绝;测试断言虽通过,CI 仍以 unhandled errors 失败。 - **现象**Vitest/jsdom 挂载 AGC 客户端时,错误报告通知调用 `@tauri-apps/api/event.listen`,因缺少 `window.__TAURI_INTERNALS__` 产生未处理拒绝;测试断言虽通过,CI 仍以 unhandled errors 失败。
@@ -62,9 +62,9 @@ Linux 本机多用户并发开发时,`npm run dev`、`npm run dev:*` 单模块
后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker``api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping``/readyz``/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。 AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker``api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping``/readyz``/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。端口健康不等于归属正确:复用前还必须证明端口上的监听进程属于当前工作树(Windows 按 `server-rs/target/debug/api-server.exe` 绝对路径与 SpacetimeDB `--data-dir` 校验,探测不可用时退化为旧行为),无法证明归属时一律不复用,改为启动本工作树自己的后端并在需要时端口漂移;否则上个工作树 Ctrl+C 残留的后端会被当成自己的后端复用,改了数据库的工作树会连到旧库。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。
Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`Windows 使用 `taskkill /PID <pid> /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc/<pid>/stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`Windows 使用 `taskkill /PID <pid> /T /F`Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc/<pid>/stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 会用空的 `RUSTC_WRAPPER` / `CARGO_BUILD_RUSTC_WRAPPER` 覆盖 `server-rs/.cargo/config.toml` 里的 `sccache`,从而直连真实 `rustc`。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志是否出现该错误,再确认脚本注入的 wrapper 为空。 Windows 本地 `npm run dev` / `npm run dev:api-server` / `npm run dev:bgfilter-worker` 会用空的 `RUSTC_WRAPPER` / `CARGO_BUILD_RUSTC_WRAPPER` 覆盖 `server-rs/.cargo/config.toml` 里的 `sccache`,从而直连真实 `rustc`。完整栈和 `dev:api-server` 把 API 与 BgFilter worker 作为一个 Rust 重启单元:源码变化时先停两个进程,再先启动并验活 worker、最后启动并验活 API,避免两个 `cargo run` 并发链接同一个 Windows 可执行文件。不要把 wrapper 绕过值写成 `rustc`Cargo 会按 wrapper 协议调用 `rustc <真实rustc路径> - ...`,最终报 `multiple input filenames provided` 并导致 api-server 无法启动。排查本地启动失败时,先看 dev 日志是否出现该错误,再确认脚本注入的 wrapper 为空。
+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.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,
};
+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
// 快照里只剩 30ParentProcessId 仍指向已消失的 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([]);
});
});
+75 -85
View File
@@ -36,6 +36,13 @@ import {
resolveApiServerLogFile, resolveApiServerLogFile,
resolveClientHost, resolveClientHost,
} from './dev-utils.mjs'; } 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. // 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 // AGC starts this scheduler through `npm --prefix` from its own package; using
@@ -917,7 +924,17 @@ class DevService {
} }
async function stopProcess(child, label) { 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; return;
} }
@@ -938,7 +955,7 @@ async function stopProcess(child, label) {
try { try {
if (process.platform === 'win32') { if (process.platform === 'win32') {
stopWindowsProcessTree(child.pid); stopWindowsProcessTree(child.pid, label);
} else { } else {
child.kill('SIGTERM'); child.kill('SIGTERM');
} }
@@ -1135,51 +1152,46 @@ async function stopStaleLocalExternalGenerationWorkers({
return stopped; return stopped;
} }
function stopWindowsProcessTree(pid) { function resolveWindowsApiServerExePath(repoRootPath = repoRoot) {
if (!pid) { return resolve(repoRootPath, 'server-rs/target/debug/api-server.exe');
return; }
function stopWindowsProcessTree(pid, label = '') {
if (!Number.isInteger(pid)) {
return [];
} }
spawnSync( const stopped = stopWindowsProcessTreeById(pid);
'powershell.exe', if (stopped.length > 1) {
[ console.log(
'-NoProfile', `[dev${label ? `:${label}` : ''}] 已停止进程树: ${stopped.join(', ')}`,
'-ExecutionPolicy', );
'Bypass', }
'-Command', return stopped;
[ }
'$ErrorActionPreference = "SilentlyContinue"',
'$root = [int]$env:GENARRATIVE_STOP_PID', // 兜底清扫:包装层(cmd.exe / cargo / npm)可能已经退出,父进程链断掉后按 PID
'$all = Get-CimInstance Win32_Process', // 遍历再也找不到真正的服务进程,因此这里按身份再清一次本工作树的后端。
'$childrenByParent = @{}', function stopWindowsWorktreeBackendProcesses({
'foreach ($process in $all) {', spacetimeDataDir = '',
' $parent = [int]$process.ParentProcessId', logStream = null,
' if (-not $childrenByParent.ContainsKey($parent)) { $childrenByParent[$parent] = @() }', snapshot = null,
' $childrenByParent[$parent] += [int]$process.ProcessId', } = {}) {
'}', if (process.platform !== 'win32') {
'$toStop = New-Object System.Collections.Generic.List[int]', return [];
'$queue = New-Object System.Collections.Generic.Queue[int]', }
'$queue.Enqueue($root)',
'while ($queue.Count -gt 0) {', const stopped = stopWindowsWorktreeProcesses({
' $current = $queue.Dequeue()', apiServerExePath: resolveWindowsApiServerExePath(),
' $toStop.Add($current)', spacetimeDataDir,
' if ($childrenByParent.ContainsKey($current)) {', snapshot,
' foreach ($child in $childrenByParent[$current]) { $queue.Enqueue($child) }', });
' }', if (stopped.length > 0) {
'}', const line = `[dev] 已清理本工作树残留后端进程: ${stopped.join(', ')}\n`;
'foreach ($id in ($toStop | Select-Object -Unique | Sort-Object -Descending)) {', process.stdout.write(line);
' Stop-Process -Id $id -Force -ErrorAction SilentlyContinue', logStream?.write(line);
'}', }
].join('\n'), return stopped;
],
{
env: {
...process.env,
GENARRATIVE_STOP_PID: String(pid),
},
stdio: 'ignore',
},
);
} }
class DevRunner { class DevRunner {
@@ -2478,6 +2490,14 @@ class DevRunner {
await this.services.get(serviceName)?.stop(); await this.services.get(serviceName)?.stop();
} }
// 复用别人启动的 SpacetimeDB 时不能连带杀掉对方的 standalone;只有本进程
// 自己拉起的 standalone 才属于本次退出的清理范围。
stopWindowsWorktreeBackendProcesses({
spacetimeDataDir: this.state.spacetimeReused
? ''
: this.options.spacetimeDataDir,
});
process.exit(code); process.exit(code);
} }
} }
@@ -2487,47 +2507,15 @@ function stopExistingWindowsApiServer(logStream) {
return; return;
} }
const apiServerExePath = resolve( const apiServerExePath = resolveWindowsApiServerExePath();
repoRoot, const snapshot = readWindowsProcessSnapshot();
'server-rs/target/debug/api-server.exe', const processIds = selectWorktreeOwnedProcessIds(snapshot, {
); apiServerExePath,
const command = [ });
'$ErrorActionPreference = "Continue"', const stopped = stopWindowsProcessIds(processIds, { waitForExitMs: 5000 });
'$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 result = spawnSync( if (stopped.length > 0) {
'powershell.exe', const line = `[dev:api-server] 已停止旧 api-server 进程: ${stopped.join(', ')}\n`;
['-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`;
process.stdout.write(line); process.stdout.write(line);
logStream?.write(line); logStream?.write(line);
} }
@@ -3505,8 +3493,10 @@ export {
resolveDevStackStatePath, resolveDevStackStatePath,
resolveLocalSpacetimeApiIdentityPath, resolveLocalSpacetimeApiIdentityPath,
resolveLocalSpacetimeRuntimeServiceBootstrapSecretPath, resolveLocalSpacetimeRuntimeServiceBootstrapSecretPath,
resolveWindowsApiServerExePath,
shouldAcceptWatchEvent, shouldAcceptWatchEvent,
shouldTrustExistingSpacetimeToken, shouldTrustExistingSpacetimeToken,
stopWindowsWorktreeBackendProcesses,
}; };
async function main() { async function main() {