diff --git a/TODO/in-progress/32-runtime-lifecycle-regression-tests.md b/TODO/in-progress/32-runtime-lifecycle-regression-tests.md new file mode 100644 index 0000000..e9fc131 --- /dev/null +++ b/TODO/in-progress/32-runtime-lifecycle-regression-tests.md @@ -0,0 +1,25 @@ +# MVP-32:Runtime 生命周期回归测试 + +- 状态:进行中 +- 优先级:P0 +- 前置任务:MVP-02、MVP-10 + +## 目标 + +防止 Workspace 恢复、Workspace 切换和 Runtime 重启并发时产生 RPC 超时或遗留进程。 + +## 任务 + +- [x] 为不同 Workspace 的并发 `start()` 增加单元测试。 +- [x] 为重复 `restart()` 增加单元测试。 +- [x] 修正 Runtime Supervisor 的跨 Workspace single-flight 和重复重启竞争。 +- [x] 扩展安装包 smoke,等待真实 Runtime ready 后正常退出。 +- [x] 安装包连续启动两次,并检查 Electron 和 OMP 没有遗留进程。 +- [x] 将快速测试接入 PR Quality,将安装包测试接入 Linux display smoke。 + +## 完成条件 + +- [x] `pnpm test` 通过。 +- [x] 本地类型检查、Lint 和格式检查通过。 +- [ ] PR Quality 通过。 +- [ ] `main` 的 Linux 安装包与 X11/Wayland smoke 通过。 diff --git a/scripts/ci-linux-display-smoke.sh b/scripts/ci-linux-display-smoke.sh index e247d41..6644d06 100755 --- a/scripts/ci-linux-display-smoke.sh +++ b/scripts/ci-linux-display-smoke.sh @@ -13,14 +13,14 @@ case "$display_server" in esac artifact_dir="${RUNNER_TEMP:-/tmp}/omp-ci" -screenshot_path="$PWD/tests/artifacts/${display_server}-smoke.png" +screenshot_path="${OMP_SMOKE_SCREENSHOT_PATH:-$PWD/tests/artifacts/${display_server}-smoke.png}" weston_pid="" smoke_home="$artifact_dir/home" smoke_config="$smoke_home/.config" smoke_data="$smoke_home/.local/share" smoke_cache="$smoke_home/.cache" -mkdir -p "$artifact_dir" tests/artifacts "$smoke_home" "$smoke_config" "$smoke_data" "$smoke_cache" +mkdir -p "$artifact_dir" "$(dirname "$screenshot_path")" "$smoke_home" "$smoke_config" "$smoke_data" "$smoke_cache" export HOME="$smoke_home" export XDG_CONFIG_HOME="$smoke_config" export XDG_DATA_HOME="$smoke_data" @@ -42,6 +42,7 @@ cleanup() { { pgrep -a -x omp-desktop || true pgrep -a -f '/electron/dist/electron' || true + pgrep -a -f '[r]untime/omp --mode rpc' || true } | sort -u >"$artifact_dir/electron-processes-after.txt" exit "$exit_code" } @@ -66,6 +67,60 @@ export OMP_SMOKE_SCREENSHOT="$screenshot_path" export GTK_IM_MODULE=ibus export XMODIFIERS=@im=ibus +smoke_runs=1 +if [[ -n "$smoke_executable" ]]; then + workspace_dir="$smoke_home/workspace" + omp_profile="$artifact_dir/omp-profile" + mkdir -p "$workspace_dir" "$omp_profile" "$XDG_CONFIG_HOME/OMP Desktop" + WORKSPACE_DIR="$workspace_dir" OMP_PROFILE="$omp_profile" node --input-type=commonjs - <<'NODE' + const fs = require('node:fs') + const path = require('node:path') + fs.writeFileSync( + path.join(process.env.OMP_PROFILE, 'models.yml'), + [ + 'providers:', + ' ci-local:', + ' baseUrl: http://127.0.0.1:9/v1', + ' api: openai-completions', + ' auth: none', + ' models:', + ' - id: ci-smoke', + ' name: CI Smoke', + ' contextWindow: 4096', + ' maxTokens: 256', + '' + ].join('\n') + ) + const statePath = path.join( + process.env.XDG_CONFIG_HOME, + 'OMP Desktop', + 'desktop-state.json' + ) + fs.writeFileSync( + statePath, + JSON.stringify({ + version: 1, + activeWorkspaceId: 'ci-workspace', + workspaces: [ + { + id: 'ci-workspace', + path: process.env.WORKSPACE_DIR, + addedAt: new Date(0).toISOString(), + lastUsedAt: new Date(0).toISOString(), + pinned: false + } + ], + sessionPreferences: {}, + ui: { runtimeNetwork: { mode: 'off' } } + }) + '\n' + ) +NODE + export PI_CODING_AGENT_DIR="$omp_profile" + export PI_NO_PTY=1 + export OMP_SMOKE_RUNTIME=true + smoke_runs=2 +fi + if [[ "$display_server" == "x11" ]]; then export XDG_SESSION_TYPE=x11 smoke_command=(node scripts/electron-smoke.mjs) @@ -74,7 +129,10 @@ if [[ "$display_server" == "x11" ]]; then else smoke_command=(pnpm smoke) fi - xvfb-run -a --server-args='-screen 0 1440x900x24 -nolisten tcp' "${smoke_command[@]}" + for attempt in $(seq 1 "$smoke_runs"); do + echo "运行 X11 smoke:attempt=$attempt/$smoke_runs" + xvfb-run -a --server-args='-screen 0 1440x900x24 -nolisten tcp' "${smoke_command[@]}" + done else unset DISPLAY export XDG_SESSION_TYPE=wayland @@ -110,7 +168,10 @@ else if [[ -n "$smoke_executable" ]]; then export OMP_SMOKE_EXECUTABLE="$smoke_executable" - node scripts/electron-smoke.mjs + for attempt in $(seq 1 "$smoke_runs"); do + echo "运行 Wayland smoke:attempt=$attempt/$smoke_runs" + node scripts/electron-smoke.mjs + done else pnpm smoke fi @@ -132,9 +193,10 @@ fi app_processes="$({ pgrep -a -x omp-desktop || true pgrep -a -f '/electron/dist/electron' || true + pgrep -a -f '[r]untime/omp --mode rpc' || true } | sort -u)" if [[ -n "$app_processes" ]]; then - echo 'Smoke 结束后仍有 Electron 进程残留' >&2 + echo 'Smoke 结束后仍有 Electron 或 OMP 进程残留' >&2 printf '%s\n' "$app_processes" >&2 exit 1 fi diff --git a/scripts/electron-smoke.mjs b/scripts/electron-smoke.mjs index a0d553c..48b405d 100644 --- a/scripts/electron-smoke.mjs +++ b/scripts/electron-smoke.mjs @@ -12,6 +12,9 @@ const useXvfb = process.platform === 'linux' && !process.env.DISPLAY const displayServer = process.env.OMP_DISPLAY_SERVER const softwareRendering = process.env.OMP_SMOKE_SOFTWARE_RENDERING === 'true' const terminateOnReady = process.env.OMP_SMOKE_TERMINATE_ON_READY === 'true' +const runtimeSmoke = process.env.OMP_SMOKE_RUNTIME === 'true' +const noSandbox = process.env.OMP_SMOKE_NO_SANDBOX === 'true' +const readyMarker = runtimeSmoke ? 'OMP_RUNTIME_SMOKE_READY' : 'OMP_SMOKE_READY' if (displayServer && !['x11', 'wayland'].includes(displayServer)) { throw new Error(`不支持的 OMP_DISPLAY_SERVER:${displayServer}`) @@ -30,10 +33,15 @@ const explicitElectronArgs = ? ['--ozone-platform=wayland'] : [] if (softwareRendering) explicitElectronArgs.push('--disable-gpu') +if (noSandbox) explicitElectronArgs.push('--no-sandbox') const entrypoint = packagedExecutable ? undefined : 'out/main/index.js' const smokeArgs = entrypoint - ? [...explicitElectronArgs, entrypoint, '--smoke'] - : [...explicitElectronArgs, '--smoke'] + ? [ + ...explicitElectronArgs, + entrypoint, + runtimeSmoke ? '--runtime-smoke' : '--smoke' + ] + : [...explicitElectronArgs, runtimeSmoke ? '--runtime-smoke' : '--smoke'] const command = displayServer ? (packagedExecutable ?? electronBinary) : useXvfb @@ -47,12 +55,12 @@ const args = displayServer packagedExecutable ?? electronBinary, '--ozone-platform=x11', ...(entrypoint ? [entrypoint] : []), - '--smoke' + runtimeSmoke ? '--runtime-smoke' : '--smoke' ] : smokeArgs console.log( - `Electron smoke 环境:arch=${process.arch} display=${displayServer ?? (useXvfb ? 'x11-xvfb' : 'auto')} executable=${packagedExecutable ?? 'source'} rendering=${softwareRendering ? 'software' : 'default'} shutdown=${terminateOnReady ? 'forced-after-ready' : 'normal'}` + `Electron smoke 环境:arch=${process.arch} display=${displayServer ?? (useXvfb ? 'x11-xvfb' : 'auto')} executable=${packagedExecutable ?? 'source'} rendering=${softwareRendering ? 'software' : 'default'} runtime=${runtimeSmoke ? 'required' : 'skipped'} shutdown=${terminateOnReady ? 'forced-after-ready' : 'normal'}` ) const child = spawn(command, args, { @@ -85,7 +93,7 @@ child.stdout.on('data', (chunk) => { const output = String(chunk) stdout += output process.stdout.write(output) - if (!rendererReady && stdout.includes('OMP_SMOKE_READY')) { + if (!rendererReady && stdout.includes(readyMarker)) { rendererReady = true clearTimeout(timeout) if (terminateOnReady) { @@ -103,11 +111,14 @@ child.stdout.on('data', (chunk) => { child.stderr.on('data', (chunk) => process.stderr.write(chunk)) -let timeout = setTimeout(() => { - terminateChild() - console.error('Electron smoke 超时') - process.exitCode = 1 -}, 20_000) +let timeout = setTimeout( + () => { + terminateChild() + console.error('Electron smoke 超时') + process.exitCode = 1 + }, + runtimeSmoke ? 45_000 : 20_000 +) child.on('error', (error) => { clearTimeout(timeout) diff --git a/src/main/index.ts b/src/main/index.ts index a48593b..40c88f6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -27,14 +27,16 @@ import { configureLinuxInputMethod } from './linux-input-method' import { registerWorkspaceFilesIpc } from './workspace-files' const development = Boolean(process.env['ELECTRON_RENDERER_URL']) -const smokeMode = process.argv.includes('--smoke') +const runtimeSmokeMode = process.argv.includes('--runtime-smoke') +const smokeMode = process.argv.includes('--smoke') || runtimeSmokeMode const setupProviderMode = process.argv.includes('--setup-provider') const supportedCliFlags = new Set([ '--version', '--disable-gpu', '--no-sandbox', '--setup-provider', - '--smoke' + '--smoke', + '--runtime-smoke' ]) function isSupportedCliArg(arg: string): boolean { return ( @@ -50,6 +52,7 @@ let mainWindow: BrowserWindow | null = null let smokeFinishing = false let shutdownStarted = false let cleanupWorkspaceFiles: (() => void) | undefined +let runtimeRestorePromise: Promise | null = null configureLinuxFileChooser(app.commandLine) const linuxInputMethod = configureLinuxInputMethod(app.commandLine) @@ -202,10 +205,32 @@ function registerIpc(): void { }) ipcMain.on(IPC_CHANNELS.rendererReady, () => { - if (smokeMode) void finishSmoke() + if (runtimeSmokeMode) void finishRuntimeSmoke() + else if (smokeMode) void finishSmoke() }) } +async function finishRuntimeSmoke(): Promise { + try { + const deadline = Date.now() + 30_000 + while (!runtimeRestorePromise) { + if (Date.now() >= deadline) + throw new Error('Runtime smoke 等待恢复任务超时') + await new Promise((resolve) => setTimeout(resolve, 50)) + } + await runtimeRestorePromise + if (runtimeSupervisor.snapshot.status !== 'ready') { + throw new Error( + `Runtime smoke 启动失败:${runtimeSupervisor.snapshot.error?.message ?? '未知错误'}` + ) + } + await finishSmoke() + } catch (error) { + log.error('Runtime smoke 失败', error) + app.exit(1) + } +} + async function finishSmoke(): Promise { if (smokeFinishing) return smokeFinishing = true @@ -250,7 +275,11 @@ async function finishSmoke(): Promise { ) + '\n' ) } - process.stdout.write('OMP_SMOKE_READY\n', () => app.exit(0)) + if (runtimeSmokeMode) await runtimeSupervisor.stop() + const marker = runtimeSmokeMode + ? 'OMP_RUNTIME_SMOKE_READY' + : 'OMP_SMOKE_READY' + process.stdout.write(`${marker}\n`, () => app.exit(0)) } catch (error) { log.error('Smoke 收尾失败', error) app.exit(1) @@ -420,7 +449,8 @@ if (hasSingleInstanceLock) { undefined, runtimeSupervisor ) - if (!smokeMode) void restoreRuntimeState() + if (!smokeMode || runtimeSmokeMode) + runtimeRestorePromise = restoreRuntimeState() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() diff --git a/src/main/runtime-supervisor.ts b/src/main/runtime-supervisor.ts index 9f7a3f2..4fcb1d0 100644 --- a/src/main/runtime-supervisor.ts +++ b/src/main/runtime-supervisor.ts @@ -237,6 +237,10 @@ export class RuntimeSupervisor extends EventEmitter { #pending = new Map() #decoder = new JsonlDecoder() #startPromise: Promise | null = null + #startingWorkspacePath: string | null = null + #startingApprovalMode: ApprovalMode | null = null + #restartPromise: Promise | null = null + #publishSnapshots = true #intentionalStop = false #lastCrashAt = 0 #workspaceEnv: NodeJS.ProcessEnv = {} @@ -311,10 +315,23 @@ export class RuntimeSupervisor extends EventEmitter { env: NodeJS.ProcessEnv = process.env, approvalMode: ApprovalMode = this.#snapshot.approvalMode ?? 'yolo' ): Promise { - if (this.#startPromise) return this.#startPromise + if (this.#startPromise) { + if ( + this.#startingWorkspacePath === workspacePath && + this.#startingApprovalMode === approvalMode + ) { + return this.#startPromise + } + await this.#startPromise.catch(() => undefined) + return this.start(workspacePath, env, approvalMode) + } + this.#startingWorkspacePath = workspacePath + this.#startingApprovalMode = approvalMode this.#startPromise = this.#start(workspacePath, env, approvalMode).finally( () => { this.#startPromise = null + this.#startingWorkspacePath = null + this.#startingApprovalMode = null } ) return this.#startPromise @@ -323,6 +340,17 @@ export class RuntimeSupervisor extends EventEmitter { async restart( approvalMode: ApprovalMode = this.#snapshot.approvalMode ?? 'yolo', env: NodeJS.ProcessEnv = this.#workspaceEnv + ): Promise { + if (this.#restartPromise) return this.#restartPromise + this.#restartPromise = this.#restart(approvalMode, env).finally(() => { + this.#restartPromise = null + }) + return this.#restartPromise + } + + async #restart( + approvalMode: ApprovalMode, + env: NodeJS.ProcessEnv ): Promise { const workspacePath = this.#snapshot.workspacePath if (!workspacePath) { @@ -793,6 +821,10 @@ export class RuntimeSupervisor extends EventEmitter { ? undefined : setTimeout(() => { this.#pending.delete(id) + const commandType = command['type'] + this.#diagnostics.write( + `RPC_TIMEOUT: command=${typeof commandType === 'string' ? commandType : 'unknown'} generation=${generation}` + ) reject( new RuntimeFailure('RPC_TIMEOUT', 'OMP RPC 请求超时', true) ) @@ -897,13 +929,18 @@ export class RuntimeSupervisor extends EventEmitter { if (generation !== this.#generation) { throw new RuntimeFailure('CRASHED', 'Runtime 连接已失效', true) } + this.#publishSnapshots = false this.#setSnapshot({ status: 'ready', error: undefined }) await this.request( { type: 'set_follow_up_mode', mode: 'one-at-a-time' }, STATE_TIMEOUT_MS ) - return await this.getState() + await this.getState() + this.#publishSnapshots = true + this.#setSnapshot({}) + return this.snapshot } catch (error) { + this.#publishSnapshots = true let failure = error instanceof RuntimeFailure ? error @@ -1288,7 +1325,7 @@ export class RuntimeSupervisor extends EventEmitter { #setSnapshot(patch: Partial): void { this.#snapshot = { ...this.#snapshot, ...patch } - this.emit('snapshot', this.snapshot) + if (this.#publishSnapshots) this.emit('snapshot', this.snapshot) } #rejectPending(error: RuntimeFailure): void { diff --git a/tests/fixtures/fake-omp.mjs b/tests/fixtures/fake-omp.mjs index 381c6d8..b3f8df8 100644 --- a/tests/fixtures/fake-omp.mjs +++ b/tests/fixtures/fake-omp.mjs @@ -1,6 +1,6 @@ import { createInterface } from 'node:readline' import { spawn } from 'node:child_process' -import { writeFileSync } from 'node:fs' +import { appendFileSync, writeFileSync } from 'node:fs' if (process.env.FAKE_GRANDCHILD_PID_FILE) { const grandchild = spawn( @@ -32,6 +32,9 @@ if (process.env.FAKE_NO_MODELS === '1') { const lines = createInterface({ input: process.stdin }) lines.on('line', (line) => { const command = JSON.parse(line) + if (process.env.FAKE_COMMAND_LOG) { + appendFileSync(process.env.FAKE_COMMAND_LOG, `${command.type}\n`) + } const response = (data) => send({ type: 'response', diff --git a/tests/main/runtime-supervisor.test.ts b/tests/main/runtime-supervisor.test.ts index 9adf935..f172e14 100644 --- a/tests/main/runtime-supervisor.test.ts +++ b/tests/main/runtime-supervisor.test.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process' -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -44,6 +44,41 @@ describe('RuntimeSupervisor', () => { }) }) + it('启动初始化完成后才发布 ready', async () => { + const fixture = resolve('tests/fixtures/fake-omp.mjs') + const commandLog = join(temporaryDirectory, 'commands.log') + supervisor = new RuntimeSupervisor({ + runtimePath: process.execPath, + diagnostics, + spawnRuntime: (_executable, args, options) => + spawn(process.execPath, [fixture, ...args], { + ...options, + env: { ...options.env, FAKE_COMMAND_LOG: commandLog }, + stdio: ['pipe', 'pipe', 'pipe'] + }) + }) + const catalogRequest = new Promise( + (resolveCatalog, rejectCatalog) => { + supervisor.on('snapshot', (snapshot: { status?: string }) => { + if (snapshot.status !== 'ready') return + void supervisor + .getAvailableModels() + .then(() => resolveCatalog()) + .catch(rejectCatalog) + }) + } + ) + + await supervisor.start(process.cwd()) + await catalogRequest + + expect((await readFile(commandLog, 'utf8')).trim().split('\n')).toEqual([ + 'set_follow_up_mode', + 'get_state', + 'get_available_models' + ]) + }) + it('使用固定参数数组传入 Session 权限', async () => { const fixture = resolve('tests/fixtures/fake-omp.mjs') let spawnedArgs: string[] = [] @@ -104,6 +139,62 @@ describe('RuntimeSupervisor', () => { expect(spawnedValues).toEqual(['login-shell', 'manual-proxy']) }) + it('不同 Workspace 的并发启动按请求顺序切换 Runtime', async () => { + const fixture = resolve('tests/fixtures/fake-omp.mjs') + const firstWorkspace = join(temporaryDirectory, 'first-workspace') + const secondWorkspace = join(temporaryDirectory, 'second-workspace') + await Promise.all([ + mkdir(firstWorkspace, { recursive: true }), + mkdir(secondWorkspace, { recursive: true }) + ]) + const spawnedWorkspaces: string[] = [] + supervisor = new RuntimeSupervisor({ + runtimePath: process.execPath, + diagnostics, + spawnRuntime: (_executable, args, options) => { + spawnedWorkspaces.push(String(options.cwd)) + return spawn(process.execPath, [fixture, ...args], { + ...options, + stdio: ['pipe', 'pipe', 'pipe'] + }) + } + }) + + const first = supervisor.start(firstWorkspace) + const second = supervisor.start(secondWorkspace) + await Promise.all([first, second]) + + expect(spawnedWorkspaces).toEqual([firstWorkspace, secondWorkspace]) + expect(supervisor.snapshot).toMatchObject({ + status: 'ready', + workspacePath: secondWorkspace + }) + }) + + it('并发重启只替换一次 Runtime', async () => { + const fixture = resolve('tests/fixtures/fake-omp.mjs') + let spawnCount = 0 + supervisor = new RuntimeSupervisor({ + runtimePath: process.execPath, + diagnostics, + spawnRuntime: (_executable, args, options) => { + spawnCount += 1 + return spawn(process.execPath, [fixture, ...args], { + ...options, + stdio: ['pipe', 'pipe', 'pipe'] + }) + } + }) + await supervisor.start(process.cwd()) + + const first = supervisor.restart() + const second = supervisor.restart() + await Promise.all([first, second]) + + expect(spawnCount).toBe(2) + expect(supervisor.snapshot.status).toBe('ready') + }) + it('关闭前刷新 Runtime 诊断日志', async () => { const flush = vi.spyOn(diagnostics, 'flush') await supervisor.start(process.cwd())