Files
stablyai--orca/tests/e2e/helpers/electron-process-shutdown.ts
2026-07-13 13:05:33 +08:00

193 lines
5.3 KiB
TypeScript

import type { ChildProcess } from 'node:child_process'
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import path from 'node:path'
import type { ElectronApplication } from '@stablyai/playwright-test'
const GRACEFUL_CLOSE_TIMEOUT_MS = 10_000
const PROCESS_EXIT_TIMEOUT_MS = 5_000
const FORCE_KILL_WAIT_MS = 2_000
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
const timeout = setTimeout(resolve, ms)
timeout.unref?.()
})
}
function hasExited(proc: ChildProcess): boolean {
return proc.exitCode !== null || proc.signalCode !== null
}
function waitForExit(proc: ChildProcess, timeoutMs: number): Promise<boolean> {
if (hasExited(proc)) {
return Promise.resolve(true)
}
return new Promise((resolve) => {
let settled = false
const finish = (exited: boolean): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
proc.off('exit', onExit)
proc.off('close', onExit)
resolve(exited)
}
const onExit = (): void => finish(true)
const timeout = setTimeout(() => finish(false), timeoutMs)
timeout.unref?.()
proc.once('exit', onExit)
proc.once('close', onExit)
})
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timeout: NodeJS.Timeout | null = null
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error(message)), timeoutMs)
timeout.unref?.()
})
])
} finally {
if (timeout) {
clearTimeout(timeout)
}
}
}
function readPosixDescendantPids(rootPid: number): number[] {
try {
const output = execFileSync('ps', ['-eo', 'pid=,ppid='], { encoding: 'utf8' })
const childrenByParent = new Map<number, number[]>()
for (const line of output.split('\n')) {
const [pidText, ppidText] = line.trim().split(/\s+/)
const pid = Number(pidText)
const ppid = Number(ppidText)
if (!Number.isInteger(pid) || !Number.isInteger(ppid)) {
continue
}
const children = childrenByParent.get(ppid) ?? []
children.push(pid)
childrenByParent.set(ppid, children)
}
const descendants: number[] = []
const stack = [...(childrenByParent.get(rootPid) ?? [])]
while (stack.length > 0) {
const pid = stack.pop()
if (!pid) {
continue
}
descendants.push(pid)
stack.push(...(childrenByParent.get(pid) ?? []))
}
return descendants
} catch {
return []
}
}
function killPid(pid: number, signal: NodeJS.Signals): void {
try {
process.kill(pid, signal)
} catch {
/* already dead or inaccessible */
}
}
async function forceKillPidTree(pid: number): Promise<void> {
if (!pid) {
return
}
if (process.platform === 'win32') {
try {
execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' })
} catch {
/* already dead or taskkill unavailable */
}
return
}
// Why: CI showed orphaned Electron renderer and shell children after the
// parent close path returned. Capture the tree before killing the root so
// descendants do not get reparented out from under the cleanup.
const pids = [...readPosixDescendantPids(pid), pid]
for (const targetPid of [...pids].toReversed()) {
killPid(targetPid, 'SIGTERM')
}
await delay(FORCE_KILL_WAIT_MS)
for (const targetPid of [...pids].toReversed()) {
killPid(targetPid, 'SIGKILL')
}
}
async function forceKillProcessTree(proc: ChildProcess): Promise<void> {
const pid = proc.pid
if (!pid || hasExited(proc)) {
return
}
await forceKillPidTree(pid)
await waitForExit(proc, PROCESS_EXIT_TIMEOUT_MS)
}
export async function closeElectronAppForE2E(app: ElectronApplication): Promise<void> {
const proc = app.process()
try {
await withTimeout(app.close(), GRACEFUL_CLOSE_TIMEOUT_MS, 'Timed out closing Electron app')
if (proc) {
const exited = await waitForExit(proc, PROCESS_EXIT_TIMEOUT_MS)
if (!exited) {
await forceKillProcessTree(proc)
}
}
} catch {
if (proc) {
await forceKillProcessTree(proc)
}
}
}
function readDaemonPidFiles(userDataDir: string): number[] {
const daemonDir = path.join(userDataDir, 'daemon')
if (!existsSync(daemonDir)) {
return []
}
const pids: number[] = []
for (const entry of readdirSync(daemonDir)) {
if (!entry.endsWith('.pid')) {
continue
}
try {
const raw = readFileSync(path.join(daemonDir, entry), 'utf8').trim()
const parsed = JSON.parse(raw) as { pid?: unknown }
if (typeof parsed.pid === 'number' && Number.isInteger(parsed.pid)) {
pids.push(parsed.pid)
}
} catch {
const pid = Number(readFileSync(path.join(daemonDir, entry), 'utf8').trim())
if (Number.isInteger(pid)) {
pids.push(pid)
}
}
}
return pids
}
export async function cleanupE2EDaemons(userDataDir: string): Promise<void> {
// Why: app quit intentionally leaves daemon PTYs alive for warm reattach.
// E2E temp profiles are deleted after each test, so their detached daemons
// must be stopped explicitly or CI accumulates orphan Electron/shell trees.
for (const pid of readDaemonPidFiles(userDataDir)) {
await forceKillPidTree(pid)
}
}