Files
Genarrative/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs
T
menghao 4494153104
Project CI / Repository checks (pull_request) Successful in 1m26s
Project CI / Backend tests (pull_request) Successful in 4m18s
Project CI / Native shell tests (pull_request) Successful in 12m18s
Project CI / Frontend tests (pull_request) Successful in 1m25s
修复AI游戏创作跨平台运行竞态
保存独立进程组标识并在启动失败或退出时清理后代进程

兼容macOS下带前置选项的npm脚本命令校验

修复进程会话输出上限投影、测试夹具与锁污染问题

收敛Agent运行时lane释放、并发锁初始化与异步审计时序竞态

补充Node与Native Shell回归测试并同步排障文档
2026-07-22 20:27:43 +08:00

453 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import http from 'node:http';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = resolve(appRoot, '../..');
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
const viteHost = '127.0.0.1';
const vitePort = 3080;
const viteUrl = `http://${viteHost}:${vitePort}/`;
const viteMarkerUrl = `${viteUrl}__agc_dev_server.json`;
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 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';
return {
apiUrl,
spacetimeUrl,
database,
spacetimeDataDir,
hasMatchingDatabase,
hasMatchingDataDir,
hasMatchingBackend,
};
}
function readBackendTargets({ requireAgcBackend = false } = {}) {
return resolveBackendTargetsFromState(readJson(devStackStatePath), {
requireAgcBackend,
});
}
async function isBackendReady() {
const { apiUrl, spacetimeUrl, hasMatchingBackend } = readBackendTargets({
requireAgcBackend: true,
});
return (
hasMatchingBackend &&
Boolean(apiUrl) &&
Boolean(spacetimeUrl) &&
(await isHttpReady(`${apiUrl}/healthz`)) &&
(await isHttpReady(`${spacetimeUrl}/v1/ping`))
);
}
async function readExistingViteServer() {
return httpGetText(viteUrl);
}
function isAiGameCreatorServer(response) {
return (
response &&
response.statusCode >= 200 &&
response.statusCode < 500 &&
response.body.includes('<title>AI 游戏创作</title>') &&
response.body.includes('/src/main.tsx')
);
}
async function isExistingViteProxyReady() {
const response = await httpGetText(`${viteUrl}api/auth/me`, 2000);
return Boolean(
response &&
response.statusCode >= 200 &&
response.statusCode < 500 &&
!response.body.includes('<title>AI 游戏创作</title>') &&
!response.body.includes('/src/main.tsx'),
);
}
async function readExistingViteMarker() {
const response = await httpGetText(viteMarkerUrl, 2000);
if (!response || response.statusCode !== 200) {
return null;
}
try {
return JSON.parse(response.body);
} catch {
return null;
}
}
async function isExistingVitePairedWithBackend(apiTarget) {
const marker = await readExistingViteMarker();
return Boolean(
marker &&
marker.schemaVersion === 1 &&
marker.app === 'ai-game-creator-shell' &&
marker.apiTarget === apiTarget,
);
}
function spawnChild(command, args, options, spawnImpl = spawn) {
const useShell = process.platform === 'win32';
const child = spawnImpl(command, args, {
...options,
shell: useShell,
// POSIX 下让每个长驻服务拥有独立进程组,退出时可以连同 npm、
// Node、Cargo 及其子进程一起清理,避免残留订阅任务持续刷日志。
detached: !useShell,
stdio: 'inherit',
});
const lifecycle = {
failure: null,
promise: null,
// detached 子进程在 POSIX 下以自身 PID 作为 PGID。leader 退出后
// child.pid 仍是清理其后代的唯一稳定句柄,必须随生命周期保留。
processGroupId: !useShell && 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 fallbackleader 已退出则仍以
// 负 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
}
}
async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await isBackendReady()) {
return readBackendTargets();
}
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,
resolveTargets = readBackendTargets,
spawnBackend = () =>
spawnChild(
npm,
[
'--prefix',
'../..',
'run',
'agc:backend',
'--',
'--database',
backendDatabase,
'--spacetime-data-dir',
backendSpacetimeDataDir,
'--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) {
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();
if (existing) {
if (
isAiGameCreatorServer(existing) &&
(await isExistingVitePairedWithBackend(apiTarget)) &&
(await isExistingViteProxyReady())
) {
console.log(
`[ai-game-creator-shell] reuse existing Vite dev server ${viteUrl}`,
);
return null;
}
if (isAiGameCreatorServer(existing)) {
throw new Error(
`${viteUrl} is already running, but its /api proxy is not connected to the paired backend. Stop it before starting Tauri dev.`,
);
}
throw new Error(
`${viteUrl} is already in use by another server. Stop it before starting Tauri dev.`,
);
}
return spawnChild(
npm,
['--prefix', '../..', 'exec', 'vite', '--', '--config', 'vite.config.ts'],
{ cwd: appRoot },
);
}
async function main() {
let backendChild = null;
let viteChild = null;
let shutdownSignal = '';
const signalHandlers = new Map();
for (const signal of ['SIGINT', 'SIGTERM']) {
const handler = () => {
shutdownSignal = signal;
stopChild(viteChild, signal);
stopChild(backendChild, signal);
};
signalHandlers.set(signal, handler);
process.on(signal, handler);
}
try {
const backend = await ensureBackend({
onBackendChild(child) {
backendChild = child;
if (shutdownSignal) {
stopChild(child, shutdownSignal);
}
},
});
backendChild = backend.backendChild;
if (shutdownSignal) {
throw new Error(`启动期收到 ${shutdownSignal},已停止配套后端`);
}
viteChild = await startVite(backend.targets.apiUrl);
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 {
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,
isDirectModuleExecution,
readChildFailure,
resolveBackendTargetsFromState,
spawnChild,
stopChild,
waitForBackendReady,
waitForChildTermination,
};
if (isDirectModuleExecution()) {
process.exitCode = await main();
}