修复 AGC 本地 BgFilter worker 启动链路 (#258)
Project CI / Repository checks (push) Successful in 3m14s
Project CI / Frontend tests (push) Successful in 3m18s
Project CI / Backend tests (push) Successful in 6m58s
Project CI / Native shell tests (push) Successful in 18m13s

backend 模式复用 Rust 双进程启动与 watcher

AGC 后端复用门禁增加 BgFilter worker readiness

补充启动编排与 ready 门禁回归测试

同步本地开发运维文档

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/258
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
This commit was merged in pull request #258.
This commit is contained in:
2026-09-03 10:15:29 +08:00
committed by 段舒康
parent 2079e274b6
commit a465e481e3
5 changed files with 86 additions and 11 deletions
@@ -79,6 +79,7 @@ function resolveBackendTargetsFromState(
) {
const apiServer = state?.services?.['api-server'];
const spacetime = state?.services?.spacetime;
const bgfilterWorker = state?.services?.['bgfilter-worker'];
const isActive = (service) =>
service && ['running', 'reused', 'starting'].includes(service.status ?? '');
const database = typeof state?.database === 'string' ? state.database : '';
@@ -104,9 +105,14 @@ function resolveBackendTargetsFromState(
: requireAgcBackend
? ''
: 'http://127.0.0.1:3101';
const bgfilterWorkerUrl =
canReuseState && isActive(bgfilterWorker) && bgfilterWorker.url
? bgfilterWorker.url
: '';
return {
apiUrl,
spacetimeUrl,
bgfilterWorkerUrl,
database,
spacetimeDataDir,
hasMatchingDatabase,
@@ -121,16 +127,22 @@ function readBackendTargets({ requireAgcBackend = false } = {}) {
});
}
async function isBackendReady() {
const { apiUrl, spacetimeUrl, hasMatchingBackend } = readBackendTargets({
requireAgcBackend: true,
});
async function isBackendReady({
state = readJson(devStackStatePath),
isReady = isHttpReady,
} = {}) {
const { apiUrl, spacetimeUrl, bgfilterWorkerUrl, hasMatchingBackend } =
resolveBackendTargetsFromState(state, {
requireAgcBackend: true,
});
return (
hasMatchingBackend &&
Boolean(apiUrl) &&
Boolean(spacetimeUrl) &&
(await isHttpReady(`${apiUrl}/healthz`)) &&
(await isHttpReady(`${spacetimeUrl}/v1/ping`))
Boolean(bgfilterWorkerUrl) &&
(await isReady(`${apiUrl}/healthz`)) &&
(await isReady(`${spacetimeUrl}/v1/ping`)) &&
(await isReady(`${bgfilterWorkerUrl}/readyz`))
);
}
@@ -668,6 +680,7 @@ function isDirectModuleExecution() {
export {
ensureBackend,
formatChildFailure,
isBackendReady,
isDirectModuleExecution,
isProcessGroupAlive,
preflightExistingVite,
@@ -7,6 +7,7 @@ import { describe, expect, test, vi } from 'vitest';
import {
ensureBackend,
isBackendReady,
isProcessGroupAlive,
preflightExistingVite,
readLinuxProcessGroupAlive,
@@ -21,7 +22,7 @@ import {
const expectedDatabase = 'genarrative-game-creator-dev';
const expectedDataDir = resolve('server-rs/.spacetimedb/ai-game-creator/data');
function backendState(spacetimeDataDir?: string) {
function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) {
return {
schemaVersion: spacetimeDataDir ? 2 : 1,
database: expectedDatabase,
@@ -35,6 +36,14 @@ function backendState(spacetimeDataDir?: string) {
status: 'running',
url: 'http://127.0.0.1:3101',
},
...(includeBgfilterWorker
? {
'bgfilter-worker': {
status: 'running',
url: 'http://127.0.0.1:8083',
},
}
: {}),
},
};
}
@@ -87,6 +96,36 @@ describe('AI 游戏创作配套后端复用门禁', () => {
expect(matching.hasMatchingBackend).toBe(true);
expect(matching.apiUrl).toBe('http://127.0.0.1:8082');
expect(matching.spacetimeUrl).toBe('http://127.0.0.1:3101');
expect(matching.bgfilterWorkerUrl).toBe('http://127.0.0.1:8083');
});
test('worker 缺失或未 ready 时不允许复用后端', async () => {
const isReady = vi.fn(async (_url: string) => true);
await expect(
isBackendReady({
state: backendState(expectedDataDir, false),
isReady,
}),
).resolves.toBe(false);
expect(isReady).not.toHaveBeenCalled();
isReady.mockImplementation(async (url) => url.endsWith('/readyz'));
await expect(
isBackendReady({
state: backendState(expectedDataDir),
isReady,
}),
).resolves.toBe(false);
expect(isReady).toHaveBeenCalledWith('http://127.0.0.1:8082/healthz');
isReady.mockImplementation(async () => true);
await expect(
isBackendReady({
state: backendState(expectedDataDir),
isReady,
}),
).resolves.toBe(true);
});
});
@@ -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 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。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 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 已退出的证据。
+2 -3
View File
@@ -1619,9 +1619,8 @@ class DevRunner {
if (command === 'backend') {
await this.startSpacetimeForFullStack();
await this.services.get('api-server').start();
await this.waitForApiServer();
this.startWatchers(['spacetime', 'api-server']);
await this.startRustServicePair();
this.startWatchers(['spacetime', 'api-server', 'bgfilter-worker']);
return;
}
+24
View File
@@ -500,6 +500,30 @@ describe('dev scheduler Rust service orchestration', () => {
]);
});
test('backend 模式复用 Rust 双进程并纳入三服务 watcher', async () => {
const { explicitOptions, options } = parseArgs(['backend'], {});
const runner = new DevRunner(options, {}, explicitOptions);
const startSpacetime = vi
.spyOn(runner, 'startSpacetimeForFullStack')
.mockResolvedValue(undefined);
const startPair = vi
.spyOn(runner, 'startRustServicePair')
.mockResolvedValue(undefined);
const startWatchers = vi
.spyOn(runner, 'startWatchers')
.mockImplementation(() => {});
await runner.startCommand('backend');
expect(startSpacetime).toHaveBeenCalledOnce();
expect(startPair).toHaveBeenCalledOnce();
expect(startWatchers).toHaveBeenCalledWith([
'spacetime',
'api-server',
'bgfilter-worker',
]);
});
test('完整栈只为两个 Rust 角色创建一套组合 watcher', () => {
const { explicitOptions, options } = parseArgs(['--watch'], {});
const runner = new DevRunner(options, {}, explicitOptions);