import { spawn } 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 { 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 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(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('陶泥儿') && 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); return { stopped: !result?.timedOut && !result?.error && [0, 128].includes(result?.code ?? 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) { 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, 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 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 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; 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), ]); 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, isProcessGroupAlive, preflightExistingVite, readChildFailure, readLinuxProcessGroupAlive, resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, stopChild, terminateChildTree, waitForBackendReady, waitForChildTermination, }; if (isDirectModuleExecution()) { process.exitCode = await main(); }