a1b9b24891
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>
981 lines
27 KiB
JavaScript
981 lines
27 KiB
JavaScript
import { spawn, spawnSync } from 'node:child_process';
|
||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||
import http from 'node:http';
|
||
import net from 'node:net';
|
||
import { resolve } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
import {
|
||
normalizeWindowsPath,
|
||
parseWindowsProcessSnapshot,
|
||
stopWindowsProcessTree,
|
||
stopWindowsWorktreeProcesses,
|
||
} from '../../../scripts/dev-windows-process.mjs';
|
||
import {
|
||
agcVitePortEnvKey,
|
||
readAgcDevEndpoint,
|
||
resolveAgcDevEndpoint,
|
||
withAgcDevEndpointEnv,
|
||
} from './dev-port.mjs';
|
||
|
||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||
const repoRoot = resolve(appRoot, '../..');
|
||
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
|
||
const apiServerExePath = resolve(
|
||
repoRoot,
|
||
'server-rs/target/debug/api-server.exe',
|
||
);
|
||
const defaultApiTarget =
|
||
process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082';
|
||
const backendDatabase = 'genarrative-game-creator-dev';
|
||
const backendSpacetimeDataDir = resolve(
|
||
repoRoot,
|
||
'server-rs/.spacetimedb/ai-game-creator/data',
|
||
);
|
||
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||
const childLifecycles = new WeakMap();
|
||
|
||
function readJson(path) {
|
||
if (!existsSync(path)) {
|
||
return null;
|
||
}
|
||
try {
|
||
return JSON.parse(readFileSync(path, 'utf8'));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function httpGetText(url, timeout = 1000) {
|
||
return new Promise((resolveRequest) => {
|
||
const request = http.get(url, { timeout }, (response) => {
|
||
let body = '';
|
||
response.setEncoding('utf8');
|
||
response.on('data', (chunk) => {
|
||
if (body.length < 4096) {
|
||
body += chunk;
|
||
}
|
||
});
|
||
response.on('end', () => {
|
||
resolveRequest({
|
||
statusCode: response.statusCode ?? 0,
|
||
body,
|
||
});
|
||
});
|
||
});
|
||
request.on('timeout', () => {
|
||
request.destroy();
|
||
resolveRequest(null);
|
||
});
|
||
request.on('error', () => resolveRequest(null));
|
||
});
|
||
}
|
||
|
||
async function isHttpReady(url) {
|
||
const response = await httpGetText(url);
|
||
return Boolean(
|
||
response && response.statusCode >= 200 && response.statusCode < 300,
|
||
);
|
||
}
|
||
|
||
function resolveBackendTargetsFromState(
|
||
state,
|
||
{
|
||
requireAgcBackend = false,
|
||
expectedDatabase = backendDatabase,
|
||
expectedSpacetimeDataDir = backendSpacetimeDataDir,
|
||
fallbackApiTarget = defaultApiTarget,
|
||
} = {},
|
||
) {
|
||
const apiServer = state?.services?.['api-server'];
|
||
const spacetime = state?.services?.spacetime;
|
||
const bgfilterWorker = state?.services?.['bgfilter-worker'];
|
||
const isActive = (service) =>
|
||
service && ['running', 'reused', 'starting'].includes(service.status ?? '');
|
||
const database = typeof state?.database === 'string' ? state.database : '';
|
||
const spacetimeDataDir =
|
||
typeof state?.spacetimeDataDir === 'string'
|
||
? resolve(state.spacetimeDataDir)
|
||
: '';
|
||
const hasMatchingDatabase = database === expectedDatabase;
|
||
const hasMatchingDataDir =
|
||
Boolean(spacetimeDataDir) &&
|
||
spacetimeDataDir === resolve(expectedSpacetimeDataDir);
|
||
const hasMatchingBackend = hasMatchingDatabase && hasMatchingDataDir;
|
||
const canReuseState = !requireAgcBackend || hasMatchingBackend;
|
||
const apiUrl =
|
||
canReuseState && isActive(apiServer) && apiServer.url
|
||
? apiServer.url
|
||
: requireAgcBackend
|
||
? ''
|
||
: fallbackApiTarget;
|
||
const spacetimeUrl =
|
||
canReuseState && isActive(spacetime) && spacetime.url
|
||
? spacetime.url
|
||
: requireAgcBackend
|
||
? ''
|
||
: 'http://127.0.0.1:3101';
|
||
const bgfilterWorkerUrl =
|
||
canReuseState && isActive(bgfilterWorker) && bgfilterWorker.url
|
||
? bgfilterWorker.url
|
||
: '';
|
||
return {
|
||
apiUrl,
|
||
spacetimeUrl,
|
||
bgfilterWorkerUrl,
|
||
database,
|
||
spacetimeDataDir,
|
||
hasMatchingDatabase,
|
||
hasMatchingDataDir,
|
||
hasMatchingBackend,
|
||
};
|
||
}
|
||
|
||
function readBackendTargets({ requireAgcBackend = false } = {}) {
|
||
return resolveBackendTargetsFromState(readJson(devStackStatePath), {
|
||
requireAgcBackend,
|
||
});
|
||
}
|
||
|
||
function readBackendServiceFailure(
|
||
state,
|
||
{
|
||
expectedDatabase = backendDatabase,
|
||
expectedSpacetimeDataDir = backendSpacetimeDataDir,
|
||
} = {},
|
||
) {
|
||
const targets = resolveBackendTargetsFromState(state, {
|
||
requireAgcBackend: true,
|
||
expectedDatabase,
|
||
expectedSpacetimeDataDir,
|
||
});
|
||
if (!targets.hasMatchingBackend) {
|
||
return null;
|
||
}
|
||
|
||
for (const serviceName of ['spacetime', 'api-server', 'bgfilter-worker']) {
|
||
const service = state?.services?.[serviceName];
|
||
if (service?.status !== 'failed') {
|
||
continue;
|
||
}
|
||
|
||
return {
|
||
serviceName,
|
||
failure: service.signal
|
||
? `signal=${service.signal}`
|
||
: `code=${service.exitCode ?? 1}`,
|
||
};
|
||
}
|
||
|
||
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({
|
||
state = readJson(devStackStatePath),
|
||
isReady = isHttpReady,
|
||
verifyOwnership = verifyAgcBackendOwnership,
|
||
onOwnershipRejected = null,
|
||
} = {}) {
|
||
const { apiUrl, spacetimeUrl, bgfilterWorkerUrl, hasMatchingBackend } =
|
||
resolveBackendTargetsFromState(state, {
|
||
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 (
|
||
(await isReady(`${apiUrl}/healthz`)) &&
|
||
(await isReady(`${spacetimeUrl}/v1/ping`)) &&
|
||
(await isReady(`${bgfilterWorkerUrl}/readyz`))
|
||
);
|
||
}
|
||
|
||
async function readExistingViteServer(endpoint = readAgcDevEndpoint()) {
|
||
return httpGetText(endpoint.url);
|
||
}
|
||
|
||
function isVitePortListening(endpoint = readAgcDevEndpoint()) {
|
||
return new Promise((resolveRequest) => {
|
||
const socket = net.connect({ host: endpoint.host, port: endpoint.port });
|
||
socket.once('connect', () => {
|
||
socket.destroy();
|
||
resolveRequest(true);
|
||
});
|
||
socket.once('error', () => resolveRequest(false));
|
||
socket.setTimeout(1000, () => {
|
||
socket.destroy();
|
||
resolveRequest(true);
|
||
});
|
||
});
|
||
}
|
||
|
||
function isAiGameCreatorServer(response) {
|
||
return (
|
||
response &&
|
||
response.statusCode >= 200 &&
|
||
response.statusCode < 500 &&
|
||
response.body.includes('<title>陶泥儿</title>') &&
|
||
response.body.includes('/src/main.tsx')
|
||
);
|
||
}
|
||
|
||
async function readExistingViteMarker(endpoint = readAgcDevEndpoint()) {
|
||
const response = await httpGetText(endpoint.markerUrl, 2000);
|
||
if (!response || response.statusCode !== 200) {
|
||
return null;
|
||
}
|
||
try {
|
||
return JSON.parse(response.body);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function preflightExistingVite({
|
||
endpoint = readAgcDevEndpoint(),
|
||
readServer = readExistingViteServer,
|
||
portListening = isVitePortListening,
|
||
readMarker = readExistingViteMarker,
|
||
} = {}) {
|
||
const existing = await readServer(endpoint);
|
||
if (!existing) {
|
||
if (await portListening(endpoint)) {
|
||
throw new Error(
|
||
`${endpoint.url} is already in use by a non-HTTP or unrecognized server. Stop it before starting Tauri dev.`,
|
||
);
|
||
}
|
||
return { status: 'available', apiTarget: '' };
|
||
}
|
||
|
||
if (!isAiGameCreatorServer(existing)) {
|
||
throw new Error(
|
||
`${endpoint.url} is already in use by another server. Stop it before starting Tauri dev.`,
|
||
);
|
||
}
|
||
|
||
const marker = await readMarker(endpoint);
|
||
const markerApiTarget =
|
||
marker?.schemaVersion === 1 &&
|
||
marker?.app === 'ai-game-creator-shell' &&
|
||
typeof marker?.apiTarget === 'string'
|
||
? marker.apiTarget
|
||
: '';
|
||
const actualTarget = markerApiTarget || 'unknown';
|
||
throw new Error(
|
||
`${endpoint.url} is already running with API target ${actualTarget}. Its owning worktree cannot be proven, so it will not be reused. Stop that Vite dev server before starting Tauri dev.`,
|
||
);
|
||
}
|
||
|
||
function spawnChild(command, args, options, spawnImpl = spawn) {
|
||
const isPosix = process.platform !== 'win32';
|
||
const useShell = options.shell ?? !isPosix;
|
||
const child = spawnImpl(command, args, {
|
||
...options,
|
||
shell: useShell,
|
||
// POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、
|
||
// Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。
|
||
detached: isPosix,
|
||
stdio: 'inherit',
|
||
});
|
||
const lifecycle = {
|
||
failure: null,
|
||
promise: null,
|
||
// detached 子进程在 POSIX 下以自身 PID 作为 PGID。leader 退出后
|
||
// child.pid 仍是清理其后代的唯一稳定句柄,必须随生命周期保留。
|
||
processGroupId: isPosix && Number.isInteger(child.pid) ? child.pid : null,
|
||
};
|
||
lifecycle.promise = new Promise((resolveLifecycle) => {
|
||
child.once('error', (error) => {
|
||
lifecycle.failure = { type: 'error', error };
|
||
resolveLifecycle(lifecycle.failure);
|
||
});
|
||
child.once('exit', (code, signal) => {
|
||
if (!lifecycle.failure) {
|
||
lifecycle.failure = { type: 'exit', code, signal };
|
||
}
|
||
resolveLifecycle(lifecycle.failure);
|
||
});
|
||
});
|
||
childLifecycles.set(child, lifecycle);
|
||
return child;
|
||
}
|
||
|
||
function readChildFailure(child) {
|
||
return childLifecycles.get(child)?.failure ?? null;
|
||
}
|
||
|
||
function waitForChildTermination(child) {
|
||
const lifecycle = childLifecycles.get(child);
|
||
if (!lifecycle) {
|
||
return Promise.resolve({
|
||
type: 'error',
|
||
error: new Error('子进程未注册生命周期监听'),
|
||
});
|
||
}
|
||
return lifecycle.promise;
|
||
}
|
||
|
||
function formatChildFailure(failure) {
|
||
if (failure?.type === 'error') {
|
||
return failure.error instanceof Error
|
||
? failure.error.message
|
||
: String(failure.error);
|
||
}
|
||
return failure?.signal
|
||
? `signal=${failure.signal}`
|
||
: `code=${failure?.code ?? 0}`;
|
||
}
|
||
|
||
function stopChild(child, signal = 'SIGTERM') {
|
||
if (!child) {
|
||
return;
|
||
}
|
||
|
||
if (process.platform !== 'win32') {
|
||
const processGroupId = childLifecycles.get(child)?.processGroupId;
|
||
if (Number.isInteger(processGroupId)) {
|
||
try {
|
||
process.kill(-processGroupId, signal);
|
||
return;
|
||
} catch (error) {
|
||
if (error?.code === 'ESRCH') {
|
||
return;
|
||
}
|
||
// leader 尚存活时保留 direct child fallback;leader 已退出则仍以
|
||
// 负 PGID kill 的失败为准,不能误以为 descendants 已清理。
|
||
if (child.exitCode != null || child.signalCode != null) {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (child.exitCode != null || child.signalCode != null) {
|
||
return;
|
||
}
|
||
try {
|
||
child.kill(signal);
|
||
} catch {
|
||
// ignore cleanup races
|
||
}
|
||
}
|
||
|
||
function readLinuxProcessGroupAlive(
|
||
processGroupId,
|
||
{ readdirImpl = readdirSync, readFileImpl = readFileSync } = {},
|
||
) {
|
||
let processIds;
|
||
try {
|
||
processIds = readdirImpl('/proc');
|
||
} catch {
|
||
return null;
|
||
}
|
||
|
||
for (const processId of processIds) {
|
||
if (!/^\d+$/.test(processId)) {
|
||
continue;
|
||
}
|
||
let stat;
|
||
try {
|
||
stat = readFileImpl(`/proc/${processId}/stat`, 'utf8');
|
||
} catch {
|
||
continue;
|
||
}
|
||
const commandEnd = stat.lastIndexOf(') ');
|
||
if (commandEnd < 0) {
|
||
continue;
|
||
}
|
||
const [state, , processGroup] = stat
|
||
.slice(commandEnd + 2)
|
||
.trim()
|
||
.split(/\s+/);
|
||
if (
|
||
Number(processGroup) === processGroupId &&
|
||
state !== 'Z' &&
|
||
state !== 'X'
|
||
) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function isProcessGroupAlive(
|
||
processGroupId,
|
||
{
|
||
platform = process.platform,
|
||
killImpl = process.kill,
|
||
readLinuxGroupAlive = readLinuxProcessGroupAlive,
|
||
} = {},
|
||
) {
|
||
if (!Number.isInteger(processGroupId)) {
|
||
return false;
|
||
}
|
||
try {
|
||
killImpl(-processGroupId, 0);
|
||
} catch (error) {
|
||
return error?.code !== 'ESRCH';
|
||
}
|
||
if (platform === 'linux') {
|
||
const linuxGroupAlive = readLinuxGroupAlive(processGroupId);
|
||
if (typeof linuxGroupAlive === 'boolean') {
|
||
return linuxGroupAlive;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async function waitUntil(check, timeoutMs, pollIntervalMs = 25) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (Date.now() < deadline) {
|
||
if (await check()) {
|
||
return true;
|
||
}
|
||
await new Promise((resolveWait) => setTimeout(resolveWait, pollIntervalMs));
|
||
}
|
||
return check();
|
||
}
|
||
|
||
function runWindowsTaskkill(
|
||
processId,
|
||
{ spawnImpl = spawn, timeoutMs = 5000 } = {},
|
||
) {
|
||
return new Promise((resolveRequest) => {
|
||
const taskkill = spawnImpl(
|
||
'taskkill.exe',
|
||
['/PID', String(processId), '/T', '/F'],
|
||
{
|
||
shell: false,
|
||
stdio: 'ignore',
|
||
windowsHide: true,
|
||
},
|
||
);
|
||
let settled = false;
|
||
const timeout = setTimeout(() => {
|
||
try {
|
||
taskkill.kill('SIGKILL');
|
||
} catch {
|
||
// ignore taskkill timeout races
|
||
}
|
||
finish({ timedOut: true, code: null, error: null });
|
||
}, timeoutMs);
|
||
const finish = (result) => {
|
||
if (settled) {
|
||
return;
|
||
}
|
||
settled = true;
|
||
clearTimeout(timeout);
|
||
resolveRequest(result);
|
||
};
|
||
taskkill.once('error', (error) =>
|
||
finish({ timedOut: false, code: null, error }),
|
||
);
|
||
taskkill.once('exit', (code) =>
|
||
finish({ timedOut: false, code: code ?? 0, error: null }),
|
||
);
|
||
});
|
||
}
|
||
|
||
async function terminateChildTree(
|
||
child,
|
||
{
|
||
platform = process.platform,
|
||
gracefulTimeoutMs = 2500,
|
||
forceTimeoutMs = 2000,
|
||
killImpl = process.kill,
|
||
taskkillImpl = runWindowsTaskkill,
|
||
} = {},
|
||
) {
|
||
if (!child) {
|
||
return { stopped: true, forced: false };
|
||
}
|
||
|
||
if (platform === 'win32') {
|
||
if (!Number.isInteger(child.pid)) {
|
||
stopChild(child, 'SIGTERM');
|
||
return { stopped: true, forced: false };
|
||
}
|
||
const result = await taskkillImpl(child.pid);
|
||
const taskkillStopped =
|
||
!result?.timedOut &&
|
||
!result?.error &&
|
||
[0, 128].includes(result?.code ?? 0);
|
||
if (taskkillStopped) {
|
||
return { stopped: true, forced: true, 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;
|
||
if (!Number.isInteger(processGroupId)) {
|
||
stopChild(child, 'SIGTERM');
|
||
const lifecycle = childLifecycles.get(child);
|
||
if (lifecycle) {
|
||
await Promise.race([
|
||
lifecycle.promise,
|
||
new Promise((resolveWait) =>
|
||
setTimeout(resolveWait, gracefulTimeoutMs),
|
||
),
|
||
]);
|
||
}
|
||
if (child.exitCode == null && child.signalCode == null) {
|
||
stopChild(child, 'SIGKILL');
|
||
return { stopped: false, forced: true };
|
||
}
|
||
return { stopped: true, forced: false };
|
||
}
|
||
|
||
stopChild(child, 'SIGTERM');
|
||
if (
|
||
await waitUntil(
|
||
() => !isProcessGroupAlive(processGroupId, { platform, killImpl }),
|
||
gracefulTimeoutMs,
|
||
)
|
||
) {
|
||
return { stopped: true, forced: false };
|
||
}
|
||
|
||
try {
|
||
killImpl(-processGroupId, 'SIGKILL');
|
||
} catch (error) {
|
||
if (error?.code !== 'ESRCH') {
|
||
return { stopped: false, forced: true, error };
|
||
}
|
||
}
|
||
const stopped = await waitUntil(
|
||
() => !isProcessGroupAlive(processGroupId, { platform, killImpl }),
|
||
forceTimeoutMs,
|
||
);
|
||
return { stopped, forced: true };
|
||
}
|
||
|
||
async function waitForBackendReady(
|
||
backendChild,
|
||
timeoutMs = 600_000,
|
||
{
|
||
checkBackendReady = (onOwnershipRejected) =>
|
||
isBackendReady({ onOwnershipRejected }),
|
||
readState = () => readJson(devStackStatePath),
|
||
resolveTargets = readBackendTargets,
|
||
} = {},
|
||
) {
|
||
const initialStateUpdatedAt = readState()?.updatedAt ?? '';
|
||
const startedAt = Date.now();
|
||
let lastOwnershipReason = '';
|
||
while (Date.now() - startedAt < timeoutMs) {
|
||
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();
|
||
}
|
||
const state = readState();
|
||
if ((state?.updatedAt ?? '') !== initialStateUpdatedAt) {
|
||
const serviceFailure = readBackendServiceFailure(state);
|
||
if (serviceFailure) {
|
||
throw new Error(
|
||
`配套后端启动失败: ${serviceFailure.serviceName} ${serviceFailure.failure}`,
|
||
);
|
||
}
|
||
}
|
||
const failure = readChildFailure(backendChild);
|
||
if (failure) {
|
||
throw new Error(`配套后端启动失败: ${formatChildFailure(failure)}`);
|
||
}
|
||
await Promise.race([
|
||
new Promise((resolveWait) => setTimeout(resolveWait, 1000)),
|
||
waitForChildTermination(backendChild),
|
||
]);
|
||
}
|
||
throw new Error('等待配套后端和数据库启动超时');
|
||
}
|
||
|
||
async function ensureBackend({
|
||
onBackendChild = () => {},
|
||
checkBackendReady = () =>
|
||
isBackendReady({
|
||
onOwnershipRejected(ownership) {
|
||
console.warn(
|
||
`[ai-game-creator-shell] 端口上的配套后端不属于当前工作树(${ownership.reason}: ${formatOwnerLabel(ownership.apiOwner ?? ownership.spacetimeOwner ?? ownership.workerOwner)}),改为启动本工作树自己的后端。`,
|
||
);
|
||
},
|
||
}),
|
||
resolveTargets = readBackendTargets,
|
||
spawnBackend = () =>
|
||
spawnChild(
|
||
npm,
|
||
[
|
||
'--prefix',
|
||
'../..',
|
||
'run',
|
||
'agc:backend',
|
||
'--',
|
||
'--database',
|
||
backendDatabase,
|
||
'--spacetime-data-dir',
|
||
backendSpacetimeDataDir,
|
||
'--preserve-database',
|
||
'--no-interactive',
|
||
],
|
||
{ cwd: appRoot },
|
||
),
|
||
waitUntilReady = waitForBackendReady,
|
||
} = {}) {
|
||
if (await checkBackendReady()) {
|
||
const targets = resolveTargets();
|
||
console.log(`[ai-game-creator-shell] reuse backend ${targets.apiUrl}`);
|
||
return { backendChild: null, targets };
|
||
}
|
||
|
||
console.log('[ai-game-creator-shell] starting backend stack');
|
||
const backendChild = spawnBackend();
|
||
try {
|
||
onBackendChild(backendChild);
|
||
const targets = await waitUntilReady(backendChild);
|
||
console.log(`[ai-game-creator-shell] backend ready ${targets.apiUrl}`);
|
||
return { backendChild, targets };
|
||
} catch (error) {
|
||
stopChild(backendChild);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) {
|
||
const { apiUrl } = readBackendTargets();
|
||
if (apiUrl !== apiTarget) {
|
||
throw new Error(
|
||
`dev-stack state API target ${apiUrl} does not match paired backend ${apiTarget}.`,
|
||
);
|
||
}
|
||
|
||
const existing = await readExistingViteServer(endpoint);
|
||
if (existing) {
|
||
if (isAiGameCreatorServer(existing)) {
|
||
throw new Error(
|
||
`${endpoint.url} is already running and cannot be safely reused. Stop it before starting Tauri dev.`,
|
||
);
|
||
}
|
||
throw new Error(
|
||
`${endpoint.url} is already in use by another server. Stop it before starting Tauri dev.`,
|
||
);
|
||
}
|
||
|
||
return spawnChild(
|
||
npm,
|
||
[
|
||
'--prefix',
|
||
'../..',
|
||
'exec',
|
||
'vite',
|
||
'--',
|
||
'--config',
|
||
'vite.config.ts',
|
||
'--port',
|
||
String(endpoint.port),
|
||
],
|
||
{ cwd: appRoot, env: withAgcDevEndpointEnv(endpoint) },
|
||
);
|
||
}
|
||
|
||
async function main() {
|
||
let backendChild = null;
|
||
let startedBackend = false;
|
||
let viteChild = null;
|
||
let shutdownSignal = '';
|
||
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']) {
|
||
const handler = () => {
|
||
shutdownSignal = signal;
|
||
stopChild(viteChild, signal);
|
||
stopChild(backendChild, signal);
|
||
// 立刻清扫,避免外层 taskkill /F 抢在 finally 之前把本进程杀掉。
|
||
sweepStartedBackend();
|
||
};
|
||
signalHandlers.set(signal, handler);
|
||
process.on(signal, handler);
|
||
}
|
||
|
||
try {
|
||
const endpoint = await resolveAgcDevEndpoint({ strictConfigured: true });
|
||
process.env[agcVitePortEnvKey] = String(endpoint.port);
|
||
await preflightExistingVite({ endpoint });
|
||
const backend = await ensureBackend({
|
||
onBackendChild(child) {
|
||
backendChild = child;
|
||
if (shutdownSignal) {
|
||
stopChild(child, shutdownSignal);
|
||
}
|
||
},
|
||
});
|
||
backendChild = backend.backendChild;
|
||
startedBackend = Boolean(backendChild);
|
||
if (shutdownSignal) {
|
||
throw new Error(`启动期收到 ${shutdownSignal},已停止配套后端`);
|
||
}
|
||
|
||
viteChild = await startVite(backend.targets.apiUrl, endpoint);
|
||
if (shutdownSignal) {
|
||
stopChild(viteChild, shutdownSignal);
|
||
throw new Error(`启动期收到 ${shutdownSignal},已停止前端服务`);
|
||
}
|
||
|
||
const children = [backendChild, viteChild].filter(Boolean);
|
||
if (children.length === 0) {
|
||
return 0;
|
||
}
|
||
|
||
const failure = await Promise.race(
|
||
children.map((child) => waitForChildTermination(child)),
|
||
);
|
||
stopChild(viteChild);
|
||
stopChild(backendChild);
|
||
return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0);
|
||
} catch (error) {
|
||
stopChild(viteChild);
|
||
stopChild(backendChild);
|
||
console.error(
|
||
`[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`,
|
||
);
|
||
return 1;
|
||
} finally {
|
||
await Promise.all([
|
||
terminateChildTree(viteChild),
|
||
terminateChildTree(backendChild),
|
||
]);
|
||
sweepStartedBackend();
|
||
for (const [signal, handler] of signalHandlers) {
|
||
process.off(signal, handler);
|
||
}
|
||
}
|
||
}
|
||
|
||
function isDirectModuleExecution() {
|
||
return Boolean(
|
||
process.argv[1] &&
|
||
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
|
||
);
|
||
}
|
||
|
||
export {
|
||
ensureBackend,
|
||
formatChildFailure,
|
||
formatOwnerLabel,
|
||
isAiGameCreatorServer,
|
||
isBackendReady,
|
||
isDirectModuleExecution,
|
||
isProcessGroupAlive,
|
||
isWorktreeApiServerOwner,
|
||
isWorktreeSpacetimeOwner,
|
||
preflightExistingVite,
|
||
readBackendServiceFailure,
|
||
readChildFailure,
|
||
readExistingViteServer,
|
||
readLinuxProcessGroupAlive,
|
||
readWindowsPortOwnerIdentities,
|
||
resolveBackendTargetsFromState,
|
||
runWindowsTaskkill,
|
||
spawnChild,
|
||
stopChild,
|
||
terminateChildTree,
|
||
verifyAgcBackendOwnership,
|
||
waitForBackendReady,
|
||
waitForChildTermination,
|
||
};
|
||
|
||
if (isDirectModuleExecution()) {
|
||
process.exitCode = await main();
|
||
}
|