Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions TODO/in-progress/32-runtime-lifecycle-regression-tests.md
Original file line number Diff line number Diff line change
@@ -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 通过。
72 changes: 67 additions & 5 deletions scripts/ci-linux-display-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
}
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
31 changes: 21 additions & 10 deletions scripts/electron-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand All @@ -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
Expand All @@ -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, {
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
40 changes: 35 additions & 5 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -50,6 +52,7 @@ let mainWindow: BrowserWindow | null = null
let smokeFinishing = false
let shutdownStarted = false
let cleanupWorkspaceFiles: (() => void) | undefined
let runtimeRestorePromise: Promise<void> | null = null

configureLinuxFileChooser(app.commandLine)
const linuxInputMethod = configureLinuxInputMethod(app.commandLine)
Expand Down Expand Up @@ -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<void> {
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<void> {
if (smokeFinishing) return
smokeFinishing = true
Expand Down Expand Up @@ -250,7 +275,11 @@ async function finishSmoke(): Promise<void> {
) + '\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)
Expand Down Expand Up @@ -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()
Expand Down
43 changes: 40 additions & 3 deletions src/main/runtime-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ export class RuntimeSupervisor extends EventEmitter {
#pending = new Map<string, PendingRequest>()
#decoder = new JsonlDecoder()
#startPromise: Promise<RuntimeSnapshot> | null = null
#startingWorkspacePath: string | null = null
#startingApprovalMode: ApprovalMode | null = null
#restartPromise: Promise<RuntimeSnapshot> | null = null
#publishSnapshots = true
#intentionalStop = false
#lastCrashAt = 0
#workspaceEnv: NodeJS.ProcessEnv = {}
Expand Down Expand Up @@ -311,10 +315,23 @@ export class RuntimeSupervisor extends EventEmitter {
env: NodeJS.ProcessEnv = process.env,
approvalMode: ApprovalMode = this.#snapshot.approvalMode ?? 'yolo'
): Promise<RuntimeSnapshot> {
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
Expand All @@ -323,6 +340,17 @@ export class RuntimeSupervisor extends EventEmitter {
async restart(
approvalMode: ApprovalMode = this.#snapshot.approvalMode ?? 'yolo',
env: NodeJS.ProcessEnv = this.#workspaceEnv
): Promise<RuntimeSnapshot> {
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<RuntimeSnapshot> {
const workspacePath = this.#snapshot.workspacePath
if (!workspacePath) {
Expand Down Expand Up @@ -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)
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1288,7 +1325,7 @@ export class RuntimeSupervisor extends EventEmitter {

#setSnapshot(patch: Partial<RuntimeSnapshot>): void {
this.#snapshot = { ...this.#snapshot, ...patch }
this.emit('snapshot', this.snapshot)
if (this.#publishSnapshots) this.emit('snapshot', this.snapshot)
}

#rejectPending(error: RuntimeFailure): void {
Expand Down
Loading