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:
2026-09-14 20:55:51 +08:00
parent 330e4d6c5b
commit 2bdee990a2
7 changed files with 672 additions and 21 deletions
@@ -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 已退出'),
);
});
});