From d522ad01355ee8df489a81b71ab4dd6d94dc791d Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 2 Sep 2026 13:27:27 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=20BgFilter=20worker=20=E5=90=AF=E5=8A=A8=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend 模式复用 Rust 双进程启动与 watcher AGC 后端复用门禁增加 BgFilter worker readiness 补充启动编排与 ready 门禁回归测试 同步本地开发运维文档 --- .../scripts/start-dev-stack.mjs | 25 ++++++++--- .../tests/start-dev-stack.test.ts | 41 ++++++++++++++++++- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- scripts/dev.mjs | 5 +-- scripts/dev.test.ts | 24 +++++++++++ 5 files changed, 86 insertions(+), 11 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 687587b18..968c8927e 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -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, diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts index 881129c9f..d9130a640 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -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); }); }); diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index bcb9b7202..944fa7356 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -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 /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。 diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 4fccb149f..0b28a3b17 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -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; } diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index a8883b337..2cd92d750 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -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);