ff84b5a308
修复 macOS 下 Unix 文件身份比较和临时目录测试兼容 隔离 AI 游戏创作本地数据库与发布身份并阻止旧 schema 降级启动 完善 Tauri 开发栈错误传播和 POSIX 子进程树清理 跳过 macOS 不支持的进程指标回调以消除周期告警 补充开发调度测试、技术方案和团队排障记忆 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/107 Co-authored-by: menghao <mh18530625731@163.com> Co-committed-by: menghao <mh18530625731@163.com>
453 lines
12 KiB
JavaScript
453 lines
12 KiB
JavaScript
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 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
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|