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 取最后一列的易错点
This commit is contained in:
@@ -9,6 +9,10 @@ import {
|
||||
const agcDevHost = '127.0.0.1';
|
||||
const legacyAgcDevPort = 3080;
|
||||
const agcVitePortEnvKey = 'GENARRATIVE_AGC_VITE_PORT';
|
||||
const agcAdminWebHost = '127.0.0.1';
|
||||
const legacyAgcAdminWebPort = 3102;
|
||||
// 与 scripts/dev.mjs 的后台 Web 端口配置保持同一环境变量名。
|
||||
const agcAdminWebPortEnvKey = 'ADMIN_WEB_PORT';
|
||||
|
||||
function readConfiguredAgcDevPort(env = process.env) {
|
||||
const rawPort = String(env[agcVitePortEnvKey] ?? '').trim();
|
||||
@@ -99,13 +103,100 @@ function withAgcDevEndpointEnv(endpoint, env = process.env) {
|
||||
};
|
||||
}
|
||||
|
||||
function readConfiguredAgcAdminWebPort(env = process.env) {
|
||||
const rawPort = String(env[agcAdminWebPortEnvKey] ?? '').trim();
|
||||
if (!rawPort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = normalizePort(rawPort, -1);
|
||||
if (port < 1024) {
|
||||
throw new Error(`${agcAdminWebPortEnvKey} 必须是 1024-65535 的有效端口`);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function createAgcAdminWebEndpoint(port, portRange = null) {
|
||||
const origin = `http://${agcAdminWebHost}:${port}`;
|
||||
return {
|
||||
host: agcAdminWebHost,
|
||||
port,
|
||||
origin,
|
||||
basePath: '/admin/',
|
||||
url: `${origin}/admin/`,
|
||||
portRange,
|
||||
};
|
||||
}
|
||||
|
||||
// AGC 开发态的后台 Web 与 `npm run dev` 的后台 Vite 共用同一套优先端口约定:
|
||||
// Linux 取当前用户端口段的 `start + 3` 槽位,非 Linux 保留 `3102` 兼容首选并允许统一漂移。
|
||||
async function resolveAgcAdminWebEndpoint({
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
strictConfigured = false,
|
||||
reservedPorts = [],
|
||||
reservePortRange = reserveLinuxDevPortRange,
|
||||
findPort = findAvailablePort,
|
||||
} = {}) {
|
||||
const configuredPort = readConfiguredAgcAdminWebPort(env);
|
||||
let portRange = null;
|
||||
let preferredPort = configuredPort ?? legacyAgcAdminWebPort;
|
||||
|
||||
if (platform === 'linux') {
|
||||
const allocation = await reservePortRange({ env });
|
||||
if (!allocation?.range) {
|
||||
throw new Error('无法取得当前 Linux 用户的 dev 端口段');
|
||||
}
|
||||
portRange = allocation.range;
|
||||
const mappedAdminWebPort = mapDevPortsToPortRange(portRange)?.adminWebPort;
|
||||
if (!Number.isInteger(mappedAdminWebPort)) {
|
||||
throw new Error(
|
||||
`当前 Linux dev 端口段 ${portRange.label} 缺少后台 Web 槽位;请先迁移为至少 6 个端口且不与其它用户重叠的端口段`,
|
||||
);
|
||||
}
|
||||
preferredPort = configuredPort ?? mappedAdminWebPort;
|
||||
}
|
||||
|
||||
const reservedPortSet = new Set(
|
||||
reservedPorts.filter((value) => Number.isInteger(value) && value > 0),
|
||||
);
|
||||
const port = await findPort({
|
||||
host: agcAdminWebHost,
|
||||
preferredPort,
|
||||
portRange,
|
||||
reservedPorts: reservedPortSet,
|
||||
strict: strictConfigured && configuredPort != null,
|
||||
});
|
||||
console.log(
|
||||
formatPortDecision({
|
||||
name: 'ai-game-creator-shell-admin-web',
|
||||
host: agcAdminWebHost,
|
||||
preferredPort,
|
||||
resolvedPort: port,
|
||||
}),
|
||||
);
|
||||
if (portRange) {
|
||||
console.log(
|
||||
`[ai-game-creator-shell] admin-web port-range: ${portRange.label}`,
|
||||
);
|
||||
}
|
||||
|
||||
return createAgcAdminWebEndpoint(port, portRange);
|
||||
}
|
||||
|
||||
export {
|
||||
agcAdminWebHost,
|
||||
agcAdminWebPortEnvKey,
|
||||
agcDevHost,
|
||||
agcVitePortEnvKey,
|
||||
createAgcAdminWebEndpoint,
|
||||
createAgcDevEndpoint,
|
||||
legacyAgcAdminWebPort,
|
||||
legacyAgcDevPort,
|
||||
readAgcDevEndpoint,
|
||||
readConfiguredAgcAdminWebPort,
|
||||
readConfiguredAgcDevPort,
|
||||
resolveAgcAdminWebEndpoint,
|
||||
resolveAgcDevEndpoint,
|
||||
withAgcDevEndpointEnv,
|
||||
};
|
||||
|
||||
@@ -14,12 +14,14 @@ import {
|
||||
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,
|
||||
@@ -32,6 +34,8 @@ 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();
|
||||
|
||||
@@ -200,36 +204,122 @@ function urlPort(url) {
|
||||
}
|
||||
}
|
||||
|
||||
// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如缺少
|
||||
// Get-NetTCPConnection),此时调用方必须退化为旧行为,不能让本地启动直接失败。
|
||||
// 端口归属探测脚本。历史实现用 `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 } = {},
|
||||
{
|
||||
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 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 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', command],
|
||||
[
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
windowsPortOwnerProbeCommand,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...env, GENARRATIVE_QUERY_PORTS: uniquePorts.join(',') },
|
||||
env: childEnv,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
@@ -240,9 +330,22 @@ function readWindowsPortOwnerIdentities(
|
||||
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);
|
||||
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;
|
||||
}
|
||||
@@ -879,10 +982,102 @@ async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) {
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -905,6 +1100,7 @@ async function main() {
|
||||
const handler = () => {
|
||||
shutdownSignal = signal;
|
||||
stopChild(viteChild, signal);
|
||||
stopChild(adminWebChild, signal);
|
||||
stopChild(backendChild, signal);
|
||||
// 立刻清扫,避免外层 taskkill /F 抢在 finally 之前把本进程杀掉。
|
||||
sweepStartedBackend();
|
||||
@@ -937,6 +1133,25 @@ async function main() {
|
||||
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;
|
||||
@@ -946,10 +1161,12 @@ async function main() {
|
||||
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)}`,
|
||||
@@ -958,6 +1175,7 @@ async function main() {
|
||||
} finally {
|
||||
await Promise.all([
|
||||
terminateChildTree(viteChild),
|
||||
terminateChildTree(adminWebChild),
|
||||
terminateChildTree(backendChild),
|
||||
]);
|
||||
sweepStartedBackend();
|
||||
@@ -975,9 +1193,12 @@ function isDirectModuleExecution() {
|
||||
}
|
||||
|
||||
export {
|
||||
agcDevAdminWebEnvKey,
|
||||
ensureAdminWeb,
|
||||
ensureBackend,
|
||||
formatChildFailure,
|
||||
formatOwnerLabel,
|
||||
formatStartupSummary,
|
||||
isAiGameCreatorServer,
|
||||
isBackendReady,
|
||||
isDirectModuleExecution,
|
||||
@@ -985,6 +1206,7 @@ export {
|
||||
isWorktreeApiServerOwner,
|
||||
isWorktreeSpacetimeOwner,
|
||||
preflightExistingVite,
|
||||
readAdminWebEnabled,
|
||||
readBackendServiceFailure,
|
||||
readChildFailure,
|
||||
readExistingViteServer,
|
||||
@@ -993,6 +1215,7 @@ export {
|
||||
resolveBackendTargetsFromState,
|
||||
runWindowsTaskkill,
|
||||
spawnChild,
|
||||
startAdminWeb,
|
||||
stopChild,
|
||||
terminateChildTree,
|
||||
verifyAgcBackendOwnership,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createAgcAdminWebEndpoint,
|
||||
createAgcDevEndpoint,
|
||||
readConfiguredAgcAdminWebPort,
|
||||
resolveAgcAdminWebEndpoint,
|
||||
resolveAgcDevEndpoint,
|
||||
withAgcDevEndpointEnv,
|
||||
} from '../scripts/dev-port.mjs';
|
||||
@@ -128,3 +131,95 @@ describe('AI 游戏创作 dev 端口', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AGC 开发态后台 Web 端口', () => {
|
||||
test('Linux 使用用户端口段的 start + 3 槽位', async () => {
|
||||
const findPort = vi.fn(async ({ preferredPort }) => preferredPort);
|
||||
const endpoint = await resolveAgcAdminWebEndpoint({
|
||||
env: { USER: 'alice', LOGNAME: 'alice' },
|
||||
platform: 'linux',
|
||||
reservePortRange: async () => ({
|
||||
username: 'alice',
|
||||
range: { start: 10000, end: 10099, label: '10000-10099' },
|
||||
}),
|
||||
findPort,
|
||||
});
|
||||
|
||||
expect(endpoint).toMatchObject({
|
||||
port: 10003,
|
||||
origin: 'http://127.0.0.1:10003',
|
||||
basePath: '/admin/',
|
||||
url: 'http://127.0.0.1:10003/admin/',
|
||||
});
|
||||
expect(findPort).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
preferredPort: 10003,
|
||||
portRange: { start: 10000, end: 10099, label: '10000-10099' },
|
||||
strict: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('AGC Vite 端口作为保留端口传入,避免被后台 Web 抢先占用', async () => {
|
||||
const findPort = vi.fn(async ({ preferredPort }) => preferredPort);
|
||||
await resolveAgcAdminWebEndpoint({
|
||||
env: {},
|
||||
platform: 'win32',
|
||||
reservedPorts: [10005, 0, null as unknown as number],
|
||||
findPort,
|
||||
});
|
||||
|
||||
const [call] = findPort.mock.calls;
|
||||
expect([...call[0].reservedPorts]).toEqual([10005]);
|
||||
});
|
||||
|
||||
test('非 Linux 保留 3102 兼容优先端口并允许统一漂移', async () => {
|
||||
const findPort = vi.fn(async ({ preferredPort }) => preferredPort + 1);
|
||||
const endpoint = await resolveAgcAdminWebEndpoint({
|
||||
env: {},
|
||||
platform: 'win32',
|
||||
findPort,
|
||||
});
|
||||
|
||||
expect(endpoint.port).toBe(3103);
|
||||
expect(endpoint.url).toBe('http://127.0.0.1:3103/admin/');
|
||||
expect(findPort).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ preferredPort: 3102, portRange: null }),
|
||||
);
|
||||
});
|
||||
|
||||
test('显式 ADMIN_WEB_PORT 与脚本统一命名并支持严格占用', async () => {
|
||||
const findPort = vi.fn(async ({ preferredPort }) => preferredPort);
|
||||
await resolveAgcAdminWebEndpoint({
|
||||
env: { ADMIN_WEB_PORT: '3109' },
|
||||
platform: 'win32',
|
||||
strictConfigured: true,
|
||||
findPort,
|
||||
});
|
||||
|
||||
expect(findPort).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ preferredPort: 3109, strict: true }),
|
||||
);
|
||||
expect(readConfiguredAgcAdminWebPort({ ADMIN_WEB_PORT: '3109' })).toBe(
|
||||
3109,
|
||||
);
|
||||
expect(readConfiguredAgcAdminWebPort({})).toBeNull();
|
||||
});
|
||||
|
||||
test('非法 ADMIN_WEB_PORT 直接失败而不是静默回退', () => {
|
||||
expect(() =>
|
||||
readConfiguredAgcAdminWebPort({ ADMIN_WEB_PORT: '80' }),
|
||||
).toThrow('ADMIN_WEB_PORT 必须是 1024-65535 的有效端口');
|
||||
expect(() =>
|
||||
readConfiguredAgcAdminWebPort({ ADMIN_WEB_PORT: 'not-a-port' }),
|
||||
).toThrow('ADMIN_WEB_PORT 必须是 1024-65535 的有效端口');
|
||||
});
|
||||
|
||||
test('后台 Web 地址固定带 /admin/ base', () => {
|
||||
expect(createAgcAdminWebEndpoint(3102, null)).toMatchObject({
|
||||
host: '127.0.0.1',
|
||||
port: 3102,
|
||||
url: 'http://127.0.0.1:3102/admin/',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,23 +2,28 @@ import { EventEmitter } from 'node:events';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
ensureAdminWeb,
|
||||
ensureBackend,
|
||||
formatOwnerLabel,
|
||||
formatStartupSummary,
|
||||
isBackendReady,
|
||||
isProcessGroupAlive,
|
||||
isWorktreeApiServerOwner,
|
||||
isWorktreeSpacetimeOwner,
|
||||
preflightExistingVite,
|
||||
readAdminWebEnabled,
|
||||
readBackendServiceFailure,
|
||||
readLinuxProcessGroupAlive,
|
||||
readWindowsPortOwnerIdentities,
|
||||
resolveBackendTargetsFromState,
|
||||
runWindowsTaskkill,
|
||||
spawnChild,
|
||||
startAdminWeb,
|
||||
stopChild,
|
||||
terminateChildTree,
|
||||
verifyAgcBackendOwnership,
|
||||
@@ -420,6 +425,70 @@ describe('AI 游戏创作配套后端归属校验', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('端口探测改用 netstat 取监听 PID,不再每次调用 WMI 的 Get-NetTCPConnection', () => {
|
||||
const spawnImpl = vi.fn((..._args: unknown[]) => ({
|
||||
status: 0,
|
||||
error: null,
|
||||
stdout: '[]',
|
||||
}));
|
||||
|
||||
readWindowsPortOwnerIdentities([8082], { spawnImpl, env: {} });
|
||||
|
||||
const probeArgs = spawnImpl.mock.calls[0][1] ?? [];
|
||||
const probeScript = String(probeArgs[probeArgs.length - 1] ?? '');
|
||||
expect(probeScript).toContain('netstat -ano -p tcp');
|
||||
expect(probeScript).not.toContain('Get-NetTCPConnection');
|
||||
});
|
||||
|
||||
test('命令行按 PID 缓存后随探测请求下发,TTL 过期即失效', () => {
|
||||
const commandLine =
|
||||
'spacetimedb-standalone.exe start --data-dir F:\\Projects\\Genarrative\\server-rs\\.spacetimedb\\ai-game-creator\\data';
|
||||
const spawnImpl = vi.fn((..._args: unknown[]) => ({
|
||||
status: 0,
|
||||
error: null,
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
port: 3102,
|
||||
processId: 4321,
|
||||
name: 'spacetimedb-standalone.exe',
|
||||
executablePath:
|
||||
'C:\\Users\\kdletters\\AppData\\Local\\SpacetimeDB\\bin\\current\\spacetimedb-standalone.exe',
|
||||
commandLine,
|
||||
},
|
||||
]),
|
||||
}));
|
||||
const commandLineCache = new Map<
|
||||
number,
|
||||
{ commandLine: string; at: number }
|
||||
>();
|
||||
let nowMs = 1_000;
|
||||
const options = {
|
||||
spawnImpl,
|
||||
env: {},
|
||||
commandLineCache,
|
||||
now: () => nowMs,
|
||||
commandLineTtlMs: 60_000,
|
||||
};
|
||||
const envAt = (call: number) =>
|
||||
(spawnImpl.mock.calls[call][2] as { env: Record<string, string> }).env;
|
||||
|
||||
readWindowsPortOwnerIdentities([3102], options);
|
||||
expect(envAt(0)).toEqual({ GENARRATIVE_QUERY_PORTS: '3102' });
|
||||
expect(commandLineCache.get(4321)).toEqual({ commandLine, at: 1_000 });
|
||||
|
||||
nowMs += 30_000;
|
||||
readWindowsPortOwnerIdentities([3102], options);
|
||||
expect(envAt(1)).toEqual({
|
||||
GENARRATIVE_QUERY_PORTS: '3102',
|
||||
GENARRATIVE_KNOWN_COMMAND_LINES: JSON.stringify({ 4321: commandLine }),
|
||||
});
|
||||
|
||||
nowMs += 61_000;
|
||||
readWindowsPortOwnerIdentities([3102], options);
|
||||
expect(envAt(2)).toEqual({ GENARRATIVE_QUERY_PORTS: '3102' });
|
||||
expect(commandLineCache.get(4321)).toEqual({ commandLine, at: nowMs });
|
||||
});
|
||||
|
||||
test('探测失败时返回 null 以触发退化分支', () => {
|
||||
expect(
|
||||
readWindowsPortOwnerIdentities([8082], {
|
||||
@@ -669,3 +738,165 @@ describe('AI 游戏创作动态端口启动前预检', () => {
|
||||
).resolves.toEqual({ status: 'available', apiTarget: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AGC 开发态后台 Web', () => {
|
||||
test('默认跟随 AGC 启动,AGC_DEV_ADMIN_WEB=0 时关闭', () => {
|
||||
expect(readAdminWebEnabled({})).toBe(true);
|
||||
expect(readAdminWebEnabled({ AGC_DEV_ADMIN_WEB: '1' })).toBe(true);
|
||||
expect(readAdminWebEnabled({ AGC_DEV_ADMIN_WEB: ' 0 ' })).toBe(false);
|
||||
});
|
||||
|
||||
test('后台 Vite 指向配套后端且严格使用解析后的端口', () => {
|
||||
const spawnImpl = vi.fn(() => ({ pid: 4242 }));
|
||||
startAdminWeb(
|
||||
'http://127.0.0.1:8084',
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port: 3103,
|
||||
basePath: '/admin/',
|
||||
url: 'http://127.0.0.1:3103/admin/',
|
||||
},
|
||||
{ env: { KEEP_ME: 'yes' }, spawnImpl },
|
||||
);
|
||||
|
||||
expect(spawnImpl).toHaveBeenCalledTimes(1);
|
||||
const [command, args, options] = spawnImpl.mock.calls[0];
|
||||
expect(command).toBe(process.platform === 'win32' ? 'npm.cmd' : 'npm');
|
||||
expect(args).toEqual([
|
||||
'--prefix',
|
||||
'../..',
|
||||
'exec',
|
||||
'vite',
|
||||
'--',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'3103',
|
||||
'--strictPort',
|
||||
]);
|
||||
expect(options.cwd).toBe(
|
||||
resolve(
|
||||
fileURLToPath(new URL('..', import.meta.url)),
|
||||
'../../apps/admin-web',
|
||||
),
|
||||
);
|
||||
expect(options.env).toMatchObject({
|
||||
KEEP_ME: 'yes',
|
||||
ADMIN_API_TARGET: 'http://127.0.0.1:8084',
|
||||
GENARRATIVE_API_TARGET: 'http://127.0.0.1:8084',
|
||||
GENARRATIVE_API_PORT: '8084',
|
||||
ADMIN_WEB_BASE: '/admin/',
|
||||
});
|
||||
});
|
||||
|
||||
test('启动汇总一次打印前端、后端、后台、数据库与 worker 实际地址', () => {
|
||||
expect(
|
||||
formatStartupSummary({
|
||||
frontendUrl: 'http://127.0.0.1:3082/',
|
||||
apiUrl: 'http://127.0.0.1:8084',
|
||||
adminWebUrl: 'http://127.0.0.1:3103/admin/',
|
||||
spacetimeUrl: 'http://127.0.0.1:3102',
|
||||
bgfilterWorkerUrl: 'http://127.0.0.1:8085',
|
||||
}),
|
||||
).toBe(
|
||||
'[ai-game-creator-shell] 启动汇总: 前端 http://127.0.0.1:3082/ | 后端 http://127.0.0.1:8084 | 后台 http://127.0.0.1:3103/admin/ | 数据库 http://127.0.0.1:3102 | bgfilter-worker http://127.0.0.1:8085',
|
||||
);
|
||||
});
|
||||
|
||||
test('关闭后台 Web 时汇总行不出现占位地址', () => {
|
||||
const summary = formatStartupSummary({
|
||||
frontendUrl: 'http://127.0.0.1:3082/',
|
||||
apiUrl: 'http://127.0.0.1:8084',
|
||||
adminWebUrl: '',
|
||||
spacetimeUrl: 'http://127.0.0.1:3102',
|
||||
bgfilterWorkerUrl: 'http://127.0.0.1:8085',
|
||||
});
|
||||
|
||||
expect(summary).not.toContain('后台');
|
||||
expect(summary).toBe(
|
||||
'[ai-game-creator-shell] 启动汇总: 前端 http://127.0.0.1:3082/ | 后端 http://127.0.0.1:8084 | 数据库 http://127.0.0.1:3102 | bgfilter-worker http://127.0.0.1:8085',
|
||||
);
|
||||
});
|
||||
|
||||
test('AGC_DEV_ADMIN_WEB=0 时既不解析端口也不启动后台 Vite', async () => {
|
||||
const resolveEndpoint = vi.fn();
|
||||
const spawnAdminWeb = vi.fn();
|
||||
const result = await ensureAdminWeb({
|
||||
apiUrl: 'http://127.0.0.1:8084',
|
||||
env: { AGC_DEV_ADMIN_WEB: '0' },
|
||||
resolveEndpoint,
|
||||
spawnAdminWeb,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ endpoint: null, child: null });
|
||||
expect(resolveEndpoint).not.toHaveBeenCalled();
|
||||
expect(spawnAdminWeb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('后台端口解析失败只告警,不阻断 AGC 启动', async () => {
|
||||
const warn = vi.fn();
|
||||
const spawnAdminWeb = vi.fn();
|
||||
const result = await ensureAdminWeb({
|
||||
apiUrl: 'http://127.0.0.1:8084',
|
||||
env: { ADMIN_WEB_PORT: '80' },
|
||||
resolveEndpoint: async () => {
|
||||
throw new Error('ADMIN_WEB_PORT 必须是 1024-65535 的有效端口');
|
||||
},
|
||||
spawnAdminWeb,
|
||||
warn,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ endpoint: null, child: null });
|
||||
expect(spawnAdminWeb).not.toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('后台 Web 未能启动'),
|
||||
);
|
||||
});
|
||||
|
||||
test('后台 Web 就绪后保留子进程句柄,退出时只告警不停机', async () => {
|
||||
const child = { pid: 5150 };
|
||||
const endpoint = {
|
||||
host: '127.0.0.1',
|
||||
port: 3103,
|
||||
basePath: '/admin/',
|
||||
url: 'http://127.0.0.1:3103/admin/',
|
||||
};
|
||||
const spawnAdminWeb = vi.fn(() => child);
|
||||
const warn = vi.fn();
|
||||
let onExit: ((failure: unknown) => void) | null = null;
|
||||
const result = await ensureAdminWeb({
|
||||
apiUrl: 'http://127.0.0.1:8084',
|
||||
env: {},
|
||||
resolveEndpoint: async ({ reservedPorts }) => {
|
||||
expect(reservedPorts).toEqual([3082]);
|
||||
return endpoint;
|
||||
},
|
||||
reservedPorts: [3082],
|
||||
spawnAdminWeb,
|
||||
waitForExit: (target: unknown) =>
|
||||
new Promise((resolveExit) => {
|
||||
expect(target).toBe(child);
|
||||
onExit = resolveExit;
|
||||
}),
|
||||
warn,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ endpoint, child });
|
||||
expect(spawnAdminWeb).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:8084',
|
||||
endpoint,
|
||||
{ env: {} },
|
||||
);
|
||||
|
||||
(onExit as unknown as (failure: unknown) => void)({
|
||||
type: 'exit',
|
||||
code: 1,
|
||||
signal: null,
|
||||
error: null,
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('后台 Web 已退出'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user