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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user