修复 AGC 本地 BgFilter worker 启动链路 (#258)
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:
@@ -79,6 +79,7 @@ function resolveBackendTargetsFromState(
|
|||||||
) {
|
) {
|
||||||
const apiServer = state?.services?.['api-server'];
|
const apiServer = state?.services?.['api-server'];
|
||||||
const spacetime = state?.services?.spacetime;
|
const spacetime = state?.services?.spacetime;
|
||||||
|
const bgfilterWorker = state?.services?.['bgfilter-worker'];
|
||||||
const isActive = (service) =>
|
const isActive = (service) =>
|
||||||
service && ['running', 'reused', 'starting'].includes(service.status ?? '');
|
service && ['running', 'reused', 'starting'].includes(service.status ?? '');
|
||||||
const database = typeof state?.database === 'string' ? state.database : '';
|
const database = typeof state?.database === 'string' ? state.database : '';
|
||||||
@@ -104,9 +105,14 @@ function resolveBackendTargetsFromState(
|
|||||||
: requireAgcBackend
|
: requireAgcBackend
|
||||||
? ''
|
? ''
|
||||||
: 'http://127.0.0.1:3101';
|
: 'http://127.0.0.1:3101';
|
||||||
|
const bgfilterWorkerUrl =
|
||||||
|
canReuseState && isActive(bgfilterWorker) && bgfilterWorker.url
|
||||||
|
? bgfilterWorker.url
|
||||||
|
: '';
|
||||||
return {
|
return {
|
||||||
apiUrl,
|
apiUrl,
|
||||||
spacetimeUrl,
|
spacetimeUrl,
|
||||||
|
bgfilterWorkerUrl,
|
||||||
database,
|
database,
|
||||||
spacetimeDataDir,
|
spacetimeDataDir,
|
||||||
hasMatchingDatabase,
|
hasMatchingDatabase,
|
||||||
@@ -121,16 +127,22 @@ function readBackendTargets({ requireAgcBackend = false } = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isBackendReady() {
|
async function isBackendReady({
|
||||||
const { apiUrl, spacetimeUrl, hasMatchingBackend } = readBackendTargets({
|
state = readJson(devStackStatePath),
|
||||||
requireAgcBackend: true,
|
isReady = isHttpReady,
|
||||||
});
|
} = {}) {
|
||||||
|
const { apiUrl, spacetimeUrl, bgfilterWorkerUrl, hasMatchingBackend } =
|
||||||
|
resolveBackendTargetsFromState(state, {
|
||||||
|
requireAgcBackend: true,
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
hasMatchingBackend &&
|
hasMatchingBackend &&
|
||||||
Boolean(apiUrl) &&
|
Boolean(apiUrl) &&
|
||||||
Boolean(spacetimeUrl) &&
|
Boolean(spacetimeUrl) &&
|
||||||
(await isHttpReady(`${apiUrl}/healthz`)) &&
|
Boolean(bgfilterWorkerUrl) &&
|
||||||
(await isHttpReady(`${spacetimeUrl}/v1/ping`))
|
(await isReady(`${apiUrl}/healthz`)) &&
|
||||||
|
(await isReady(`${spacetimeUrl}/v1/ping`)) &&
|
||||||
|
(await isReady(`${bgfilterWorkerUrl}/readyz`))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,6 +680,7 @@ function isDirectModuleExecution() {
|
|||||||
export {
|
export {
|
||||||
ensureBackend,
|
ensureBackend,
|
||||||
formatChildFailure,
|
formatChildFailure,
|
||||||
|
isBackendReady,
|
||||||
isDirectModuleExecution,
|
isDirectModuleExecution,
|
||||||
isProcessGroupAlive,
|
isProcessGroupAlive,
|
||||||
preflightExistingVite,
|
preflightExistingVite,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { describe, expect, test, vi } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ensureBackend,
|
ensureBackend,
|
||||||
|
isBackendReady,
|
||||||
isProcessGroupAlive,
|
isProcessGroupAlive,
|
||||||
preflightExistingVite,
|
preflightExistingVite,
|
||||||
readLinuxProcessGroupAlive,
|
readLinuxProcessGroupAlive,
|
||||||
@@ -21,7 +22,7 @@ import {
|
|||||||
const expectedDatabase = 'genarrative-game-creator-dev';
|
const expectedDatabase = 'genarrative-game-creator-dev';
|
||||||
const expectedDataDir = resolve('server-rs/.spacetimedb/ai-game-creator/data');
|
const expectedDataDir = resolve('server-rs/.spacetimedb/ai-game-creator/data');
|
||||||
|
|
||||||
function backendState(spacetimeDataDir?: string) {
|
function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) {
|
||||||
return {
|
return {
|
||||||
schemaVersion: spacetimeDataDir ? 2 : 1,
|
schemaVersion: spacetimeDataDir ? 2 : 1,
|
||||||
database: expectedDatabase,
|
database: expectedDatabase,
|
||||||
@@ -35,6 +36,14 @@ function backendState(spacetimeDataDir?: string) {
|
|||||||
status: 'running',
|
status: 'running',
|
||||||
url: 'http://127.0.0.1:3101',
|
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.hasMatchingBackend).toBe(true);
|
||||||
expect(matching.apiUrl).toBe('http://127.0.0.1:8082');
|
expect(matching.apiUrl).toBe('http://127.0.0.1:8082');
|
||||||
expect(matching.spacetimeUrl).toBe('http://127.0.0.1:3101');
|
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_*` 口径。
|
后端日志默认写入 `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 已退出的证据。
|
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
@@ -1619,9 +1619,8 @@ class DevRunner {
|
|||||||
|
|
||||||
if (command === 'backend') {
|
if (command === 'backend') {
|
||||||
await this.startSpacetimeForFullStack();
|
await this.startSpacetimeForFullStack();
|
||||||
await this.services.get('api-server').start();
|
await this.startRustServicePair();
|
||||||
await this.waitForApiServer();
|
this.startWatchers(['spacetime', 'api-server', 'bgfilter-worker']);
|
||||||
this.startWatchers(['spacetime', 'api-server']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
test('完整栈只为两个 Rust 角色创建一套组合 watcher', () => {
|
||||||
const { explicitOptions, options } = parseArgs(['--watch'], {});
|
const { explicitOptions, options } = parseArgs(['--watch'], {});
|
||||||
const runner = new DevRunner(options, {}, explicitOptions);
|
const runner = new DevRunner(options, {}, explicitOptions);
|
||||||
|
|||||||
Reference in New Issue
Block a user