修复 AGC 后端端口冲突等待
将 backend 模式的 BgFilter worker 纳入端口漂移。 启动等待立即传播匹配后端状态中的失败服务。 补充启动器回归测试和本地开发排障说明。
This commit is contained in:
@@ -127,6 +127,39 @@ function readBackendTargets({ requireAgcBackend = false } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function isBackendReady({
|
||||
state = readJson(devStackStatePath),
|
||||
isReady = isHttpReady,
|
||||
@@ -505,11 +538,29 @@ async function terminateChildTree(
|
||||
return { stopped, forced: true };
|
||||
}
|
||||
|
||||
async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
|
||||
async function waitForBackendReady(
|
||||
backendChild,
|
||||
timeoutMs = 600_000,
|
||||
{
|
||||
checkBackendReady = isBackendReady,
|
||||
readState = () => readJson(devStackStatePath),
|
||||
resolveTargets = readBackendTargets,
|
||||
} = {},
|
||||
) {
|
||||
const initialStateUpdatedAt = readState()?.updatedAt ?? '';
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await isBackendReady()) {
|
||||
return readBackendTargets();
|
||||
if (await checkBackendReady()) {
|
||||
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) {
|
||||
@@ -684,6 +735,7 @@ export {
|
||||
isDirectModuleExecution,
|
||||
isProcessGroupAlive,
|
||||
preflightExistingVite,
|
||||
readBackendServiceFailure,
|
||||
readChildFailure,
|
||||
readLinuxProcessGroupAlive,
|
||||
resolveBackendTargetsFromState,
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
isBackendReady,
|
||||
isProcessGroupAlive,
|
||||
preflightExistingVite,
|
||||
readBackendServiceFailure,
|
||||
readLinuxProcessGroupAlive,
|
||||
resolveBackendTargetsFromState,
|
||||
runWindowsTaskkill,
|
||||
spawnChild,
|
||||
stopChild,
|
||||
terminateChildTree,
|
||||
waitForBackendReady,
|
||||
waitForChildTermination,
|
||||
} from '../scripts/start-dev-stack.mjs';
|
||||
|
||||
@@ -26,6 +28,7 @@ function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) {
|
||||
return {
|
||||
schemaVersion: spacetimeDataDir ? 2 : 1,
|
||||
database: expectedDatabase,
|
||||
updatedAt: '',
|
||||
...(spacetimeDataDir ? { spacetimeDataDir } : {}),
|
||||
services: {
|
||||
'api-server': {
|
||||
@@ -127,6 +130,47 @@ describe('AI 游戏创作配套后端复用门禁', () => {
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('后端服务失败时返回具体失败服务,避免外层无限等待', () => {
|
||||
const state = backendState(expectedDataDir);
|
||||
state.services['bgfilter-worker'].status = 'failed';
|
||||
state.services['bgfilter-worker'].exitCode = 1;
|
||||
state.services['bgfilter-worker'].signal = null;
|
||||
|
||||
expect(readBackendServiceFailure(state)).toEqual({
|
||||
serviceName: 'bgfilter-worker',
|
||||
failure: 'code=1',
|
||||
});
|
||||
});
|
||||
|
||||
test('不匹配的旧状态失败记录不会阻断当前后端启动', () => {
|
||||
const state = backendState(resolve('server-rs/.spacetimedb/other/data'));
|
||||
state.services['bgfilter-worker'].status = 'failed';
|
||||
state.services['bgfilter-worker'].exitCode = 1;
|
||||
|
||||
expect(readBackendServiceFailure(state)).toBeNull();
|
||||
});
|
||||
|
||||
test('等待后端时立即传播状态文件中的服务失败', async () => {
|
||||
const initialState = backendState(expectedDataDir);
|
||||
initialState.updatedAt = '2026-09-04T08:00:00.000Z';
|
||||
const state = backendState(expectedDataDir);
|
||||
state.updatedAt = '2026-09-04T08:00:01.000Z';
|
||||
state.services['bgfilter-worker'].status = 'failed';
|
||||
state.services['bgfilter-worker'].exitCode = 98;
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
});
|
||||
let readCount = 0;
|
||||
|
||||
await expect(
|
||||
waitForBackendReady(child, 100, {
|
||||
checkBackendReady: async () => false,
|
||||
readState: () => (readCount++ === 0 ? initialState : state),
|
||||
}),
|
||||
).rejects.toThrow('配套后端启动失败: bgfilter-worker code=98');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI 游戏创作启动子进程生命周期', () => {
|
||||
|
||||
@@ -58,7 +58,7 @@ Linux 本机多用户并发开发时,`npm run dev`、`npm run dev:*` 单模块
|
||||
|
||||
后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
|
||||
|
||||
AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。
|
||||
AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。
|
||||
|
||||
Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID <pid> /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc/<pid>/stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
|
||||
|
||||
|
||||
@@ -1433,6 +1433,7 @@ class DevRunner {
|
||||
|
||||
if (
|
||||
command === 'all' ||
|
||||
command === 'backend' ||
|
||||
command === 'api-server' ||
|
||||
command === 'bgfilter-worker'
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user