Files
Genarrative/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs
T
kdletters 2bdee990a2 AGC 开发态补齐后台 Web 启动与端口汇总,端口归属探测从 WMI 换成 netstat
- dev-port.mjs 新增 resolveAgcAdminWebEndpoint:后台 Web 端口按 Linux 用户端口段 start + 3、非 Linux 优先 3102 解析,ADMIN_WEB_PORT 可显式指定,并把已解析的 AGC Vite 端口列为保留端口
- start-dev-stack.mjs 随 AGC 一起拉起 apps/admin-web(AGC_DEV_ADMIN_WEB=0 关闭):与 AGC Vite 一样由启动器直接持有并纳入信号与退出收束,不走会整体重写 .app/dev-stack.json 的 dev:admin-web
- start-dev-stack.mjs 新增启动汇总行,一次给出前端、后端、后台、数据库与 bgfilter-worker 的实际地址,端口漂移后以该行为准
- start-dev-stack.mjs 端口归属探测改用 netstat -ano 取端口到 PID、.NET Process 读可执行文件路径,仅在核对 SpacetimeDB --data-dir 归属时按 PID 取命令行并做 5 分钟 TTL 缓存:本机实测单轮探测由约 43 秒降到 0.37 秒,agc:serve 的 starting backend stack 到 backend ready 由约 80 秒降到 16.7 秒
- 后台 Web 端口解析或启动失败只告警,不阻断也不连带停止 AGC 客户端与配套后端
- tests/dev-port.test.ts、tests/start-dev-stack.test.ts 补充用例覆盖后台 Web 端口解析与严格占用、失败软化、启动汇总、netstat 探测脚本与命令行缓存失效
- docs/【开发运维】与 docs/technical 同步 AGC 开发态启动口径,pitfalls.md 记录 WMI 慢速探测的实测数据、netstat 改法与 PID 取最后一列的易错点
2026-09-14 21:02:08 +08:00

1229 lines
36 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, 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,
resolveAgcAdminWebEndpoint,
resolveAgcDevEndpoint,
withAgcDevEndpointEnv,
} from './dev-port.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = resolve(appRoot, '../..');
const adminWebDir = resolve(repoRoot, 'apps/admin-web');
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',
);
// 后台 Web 默认跟随 AGC 一起起来,便于联调后台页面;`AGC_DEV_ADMIN_WEB=0` 可关闭。
const agcDevAdminWebEnvKey = 'AGC_DEV_ADMIN_WEB';
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,
expectedRepoRoot = repoRoot,
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 instanceId =
typeof state?.instanceId === 'string' ? state.instanceId.trim() : '';
const hasMatchingRepoRoot =
typeof state?.repoRoot === 'string' &&
resolve(state.repoRoot) === resolve(expectedRepoRoot);
const hasMatchingInstance =
Boolean(instanceId) &&
[apiServer, spacetime, bgfilterWorker]
.filter(Boolean)
.every(
(service) =>
service.repoRoot &&
resolve(service.repoRoot) === resolve(expectedRepoRoot) &&
service.instanceId === instanceId,
);
const hasMatchingBackend =
hasMatchingDatabase &&
hasMatchingDataDir &&
(!requireAgcBackend || (hasMatchingRepoRoot && hasMatchingInstance));
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,
hasMatchingRepoRoot,
hasMatchingInstance,
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;
}
}
// 端口归属探测脚本。历史实现用 `Get-NetTCPConnection` 取监听进程,而它底层走
// WMI:实测单端口单次 11.2 秒、再叠加每个 PID 的 `Get-CimInstance` 3.3 秒,
// 一轮探测约 43 秒,直接把"配套后端就绪"等待拖到分钟级。改用原生
// `netstat -ano`(约 30 毫秒)取端口 -> PID,再用 .NET `Process` 读进程名和
// 可执行文件路径(毫秒级);只有核对 SpacetimeDB `--data-dir` 归属时才按 PID
// 取命令行,并允许调用方把已知命令行传进来复用。
const windowsPortOwnerProbeCommand = [
'$ErrorActionPreference = "SilentlyContinue"',
'$queriedPorts = @()',
'foreach ($raw in ($env:GENARRATIVE_QUERY_PORTS -split ",")) {',
' if ($raw -match "^\\d+$") { $queriedPorts += [int]$raw }',
'}',
'$knownCommandLines = @{}',
'if ($env:GENARRATIVE_KNOWN_COMMAND_LINES) {',
' try {',
' foreach ($property in (ConvertFrom-Json $env:GENARRATIVE_KNOWN_COMMAND_LINES).PSObject.Properties) {',
' $knownCommandLines[[int]$property.Name] = [string]$property.Value',
' }',
' } catch { }',
'}',
'$listenerPidByPort = @{}',
'foreach ($line in (netstat -ano -p tcp)) {',
' $fields = @($line -split "\\s+" | Where-Object { $_ })',
' if ($fields.Count -lt 4) { continue }',
' if ($fields[0] -ne "TCP") { continue }',
' # A listening socket always has foreign address 0.0.0.0:0 / [::]:0, which',
' # is locale-independent unlike the localized netstat State column.',
' if ($fields[2] -notmatch ":0$") { continue }',
' $localPort = [int]($fields[1].Split(":")[-1])',
' if ($queriedPorts -notcontains $localPort) { continue }',
' # The PID is the last column; do not hardcode its index.',
' if ($fields[-1] -notmatch "^\\d+$") { continue }',
' $listenerPidByPort[$localPort] = [int]$fields[-1]',
'}',
'$result = @()',
'foreach ($port in ($listenerPidByPort.Keys | Sort-Object)) {',
' $processId = $listenerPidByPort[$port]',
' $name = $null',
' $executablePath = $null',
' $commandLine = $null',
' try {',
' $process = [System.Diagnostics.Process]::GetProcessById($processId)',
' $name = $process.ProcessName + ".exe"',
' try { $executablePath = $process.MainModule.FileName } catch { }',
' } catch { }',
' if ($knownCommandLines.ContainsKey($processId)) {',
' $commandLine = $knownCommandLines[$processId]',
' } elseif (($name -like "spacetime*") -or (-not $executablePath)) {',
' try { $commandLine = (Get-CimInstance Win32_Process -Filter ("ProcessId=" + $processId)).CommandLine } catch { }',
' }',
' $result += [pscustomobject]@{ port = [int]$port; processId = $processId; name = $name; executablePath = $executablePath; commandLine = $commandLine }',
'}',
'ConvertTo-Json -InputObject @($result) -Compress',
].join('\n');
// 进程命令行在进程生命周期内不变,但 PID 会被系统复用;按 PID 记 TTL 缓存,
// 让"等配套后端就绪"的轮询只在首个周期付出 WMI 成本。TTL 取 5 分钟:本轮实测
// 这台机器上首次 WMI 调用约 18 秒(热调用 3.3 秒),而 PID 在 5 分钟内被复用
// 成另一个运行本工作树 data dir 的 SpacetimeDB 才能造成误判,概率可忽略。
// 默认实现才缓存,注入实现(测试)与显式 env 始终重新读取。
const WINDOWS_COMMAND_LINE_CACHE_TTL_MS = 300_000;
const windowsPortOwnerCommandLineCache = new Map();
function resolveCommandLineCache({ spawnImpl, env }) {
return spawnImpl === spawnSync && env === process.env
? windowsPortOwnerCommandLineCache
: new Map();
}
// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如系统缺少
// netstat),此时调用方必须退化为旧行为,不能让本地启动直接失败。
function readWindowsPortOwnerIdentities(
ports,
{
spawnImpl = spawnSync,
env = process.env,
now = Date.now,
commandLineTtlMs = WINDOWS_COMMAND_LINE_CACHE_TTL_MS,
commandLineCache = resolveCommandLineCache({ spawnImpl, env }),
} = {},
) {
const uniquePorts = [...new Set(ports.filter((port) => port > 0))];
if (uniquePorts.length === 0) {
return null;
}
const knownCommandLines = {};
for (const [processId, record] of [...commandLineCache]) {
if (record && now() - record.at < commandLineTtlMs) {
knownCommandLines[processId] = record.commandLine;
} else {
commandLineCache.delete(processId);
}
}
const childEnv = {
...env,
GENARRATIVE_QUERY_PORTS: uniquePorts.join(','),
};
if (Object.keys(knownCommandLines).length > 0) {
childEnv.GENARRATIVE_KNOWN_COMMAND_LINES =
JSON.stringify(knownCommandLines);
}
const result = spawnImpl(
'powershell.exe',
[
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-Command',
windowsPortOwnerProbeCommand,
],
{
encoding: 'utf8',
env: childEnv,
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) {
continue;
}
const processId = Number(entry?.processId);
if (
Number.isInteger(processId) &&
processId > 0 &&
typeof entry?.commandLine === 'string' &&
entry.commandLine
) {
commandLineCache.set(processId, {
commandLine: entry.commandLine,
at: now(),
});
}
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,
// npm.cmd and the Windows shell otherwise create a visible console for
// every service in the dev stack. Their stdout/stderr is already inherited
// by the launcher, so no separate terminal window is useful.
windowsHide: process.platform === 'win32' ? true : options.windowsHide,
// 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 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
}
}
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) },
);
}
function readAdminWebEnabled(env = process.env) {
return String(env[agcDevAdminWebEnvKey] ?? '').trim() !== '0';
}
// 后台 Web 与 AGC Vite 一样直接由本启动器持有,不经过 `dev.mjs admin-web`
// 后者会整体重写 `.app/dev-stack.json`,把本次配套后端的状态覆盖掉。
function startAdminWeb(
apiUrl,
endpoint,
{ env = process.env, spawnImpl = spawnChild } = {},
) {
return spawnImpl(
npm,
[
'--prefix',
'../..',
'exec',
'vite',
'--',
'--host',
endpoint.host,
'--port',
String(endpoint.port),
'--strictPort',
],
{
cwd: adminWebDir,
env: {
...env,
ADMIN_API_TARGET: apiUrl,
GENARRATIVE_API_TARGET: apiUrl,
GENARRATIVE_API_PORT: String(urlPort(apiUrl) || 8082),
ADMIN_WEB_BASE: endpoint.basePath,
},
},
);
}
function formatStartupSummary({
frontendUrl = '',
apiUrl = '',
adminWebUrl = '',
spacetimeUrl = '',
bgfilterWorkerUrl = '',
} = {}) {
const segments = [
['前端', frontendUrl],
['后端', apiUrl],
['后台', adminWebUrl],
['数据库', spacetimeUrl],
['bgfilter-worker', bgfilterWorkerUrl],
]
.filter(([, value]) => Boolean(value))
.map(([label, value]) => `${label} ${value}`);
return `[ai-game-creator-shell] 启动汇总: ${segments.join(' | ')}`;
}
// 后台 Web 是可选联调服务:端口解析或启动失败只告警,不能阻断 AGC 客户端与配套后端。
async function ensureAdminWeb({
apiUrl,
reservedPorts = [],
env = process.env,
enabled = readAdminWebEnabled(env),
resolveEndpoint = resolveAgcAdminWebEndpoint,
spawnAdminWeb = startAdminWeb,
waitForExit = waitForChildTermination,
warn = (message) => console.warn(message),
} = {}) {
if (!enabled) {
return { endpoint: null, child: null };
}
try {
const endpoint = await resolveEndpoint({ env, reservedPorts });
const child = spawnAdminWeb(apiUrl, endpoint, { env });
waitForExit(child).then((failure) => {
warn(
`[ai-game-creator-shell] 后台 Web 已退出(${formatChildFailure(failure)}),AGC 继续运行。`,
);
});
return { endpoint, child };
} catch (error) {
warn(
`[ai-game-creator-shell] 后台 Web 未能启动(${
error instanceof Error ? error.message : String(error)
}),AGC 继续运行。`,
);
return { endpoint: null, child: null };
}
}
async function main() {
let backendChild = null;
let startedBackend = false;
let viteChild = null;
let adminWebChild = 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(adminWebChild, 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 adminWeb = await ensureAdminWeb({
apiUrl: backend.targets.apiUrl,
// AGC Vite 端口尚未监听,必须显式保留,避免被后台 Web 抢先占用。
reservedPorts: [endpoint.port],
});
adminWebChild = adminWeb.child;
if (shutdownSignal) {
throw new Error(`启动期收到 ${shutdownSignal},已停止后台 Web`);
}
console.log(
formatStartupSummary({
frontendUrl: endpoint.url,
apiUrl: backend.targets.apiUrl,
adminWebUrl: adminWeb.endpoint?.url ?? '',
spacetimeUrl: backend.targets.spacetimeUrl,
bgfilterWorkerUrl: backend.targets.bgfilterWorkerUrl,
}),
);
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(adminWebChild);
stopChild(backendChild);
return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0);
} catch (error) {
stopChild(viteChild);
stopChild(adminWebChild);
stopChild(backendChild);
console.error(
`[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`,
);
return 1;
} finally {
await Promise.all([
terminateChildTree(viteChild),
terminateChildTree(adminWebChild),
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 {
agcDevAdminWebEnvKey,
ensureAdminWeb,
ensureBackend,
formatChildFailure,
formatOwnerLabel,
formatStartupSummary,
isAiGameCreatorServer,
isBackendReady,
isDirectModuleExecution,
isProcessGroupAlive,
isWorktreeApiServerOwner,
isWorktreeSpacetimeOwner,
preflightExistingVite,
readAdminWebEnabled,
readBackendServiceFailure,
readChildFailure,
readExistingViteServer,
readLinuxProcessGroupAlive,
readWindowsPortOwnerIdentities,
resolveBackendTargetsFromState,
runWindowsTaskkill,
spawnChild,
startAdminWeb,
stopChild,
terminateChildTree,
verifyAgcBackendOwnership,
waitForBackendReady,
waitForChildTermination,
};
if (isDirectModuleExecution()) {
process.exitCode = await main();
}