修复 AGC 后端端口冲突等待
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 4m8s
Project CI / Native shell tests (pull_request) Failing after 15m29s

将 backend 模式的 BgFilter worker 纳入端口漂移。

启动等待立即传播匹配后端状态中的失败服务。

补充启动器回归测试和本地开发排障说明。
This commit is contained in:
2026-09-04 16:21:04 +08:00
parent 82f4abc0d2
commit 2179224cfb
4 changed files with 101 additions and 4 deletions
@@ -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 已退出的证据。
+1
View File
@@ -1433,6 +1433,7 @@ class DevRunner {
if (
command === 'all' ||
command === 'backend' ||
command === 'api-server' ||
command === 'bgfilter-worker'
) {