mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
* Open native chats on the phone in place * Preserve desktop host and agent when opening chats * Preserve chat opening and captured lifecycle contracts * test: cover sender focus and unconfirmed chat close outcomes * test: exercise structured worker CLI lifecycle through runtime socket * test: use checked parked mail observation in CLI integration * test: validate provider notification correlation identities * test: construct checked lifecycle and navigation fixtures * Keep cross-program session proofs in their test program * Localize mobile chat opening feedback and complete runtime contracts * Use the mocked text component identity in History feedback proof * Temporarily expose grown E2E type diagnostics * Keep E2E timer and fetch fixtures in their runtime domains * Reconcile decreased E2E wait pattern counts * Preserve live test clocks and expose remaining type diagnostics * Keep mobile session data types independent of native views * Complete structured session wire surface fixtures * Expose existing grown E2E type diagnostics temporarily * Preserve checked wire fixture tab mock signatures * Preserve worker redrive when automatic close is refused * Honor automatic close admission before settlement and retirement * test: retain History reveal and close publication race * Match automation reveal callback to activation result * Align CLI integration fixture with current settings and output * Prevent retained chats from reopening removed workspaces * Import notification types directly from their owner * Unify local removal fences for accepted worktree aliases * Keep cross-surface authority tests in the combined test project * Initialize coordinator mail test workspace ownership * Register the retained automation chat test workspace * Use checked inputs in worker start failure test * Preserve the automation test repository host literal
336 lines
9.8 KiB
TypeScript
336 lines
9.8 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 { cleanupE2ECrashpad } from './electron-crashpad-cleanup'
|
|
import { recordElectronShutdownTiming } from './electron-shutdown-timing'
|
|
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 releaseExitedProcessPipes(proc: ChildProcess): void {
|
|
if (!hasExited(proc)) {
|
|
return
|
|
}
|
|
// Detached SSH helpers can retain inherited pipes after Electron itself exits.
|
|
for (const stream of proc.stdio) {
|
|
stream?.destroy()
|
|
}
|
|
}
|
|
|
|
export 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 */
|
|
}
|
|
}
|
|
|
|
function hasPidExited(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0)
|
|
return false
|
|
} catch (error) {
|
|
return error instanceof Error && 'code' in error && error.code === 'ESRCH'
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Capture descendants before killing the root or reparenting can strand Electron/shell children.
|
|
const pids = [...readPosixDescendantPids(pid), pid]
|
|
for (const targetPid of [...pids].toReversed()) {
|
|
killPid(targetPid, 'SIGTERM')
|
|
}
|
|
const deadline = performance.now() + FORCE_KILL_WAIT_MS
|
|
while (true) {
|
|
if (pids.every(hasPidExited)) {
|
|
return
|
|
}
|
|
const remainingMs = deadline - performance.now()
|
|
if (remainingMs <= 0) {
|
|
break
|
|
}
|
|
await delay(Math.min(50, remainingMs))
|
|
}
|
|
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)
|
|
}
|
|
|
|
// SIGKILL the whole tree without shutdown hooks for force-quit scenarios; use ordinary close for graceful persistence.
|
|
export async function forceQuitElectronAppForE2E(
|
|
app: ElectronApplication,
|
|
options: { preserveDaemons?: boolean } = {}
|
|
): Promise<void> {
|
|
const proc = app.process()
|
|
const pid = proc.pid
|
|
if (pid) {
|
|
if (options.preserveDaemons) {
|
|
// Chromium helpers hold Windows profile handles; Electron's list excludes detached daemons.
|
|
const appPids = await app.evaluate(({ app }) => app.getAppMetrics().map(({ pid }) => pid))
|
|
for (const targetPid of new Set([pid, ...appPids])) {
|
|
killPid(targetPid, 'SIGKILL')
|
|
}
|
|
} else if (process.platform === 'win32') {
|
|
try {
|
|
execFileSync('taskkill', ['/pid', String(pid), '/T', '/F'], {
|
|
stdio: 'ignore'
|
|
})
|
|
} catch {
|
|
/* already dead or taskkill unavailable */
|
|
}
|
|
} else {
|
|
// Capture the tree before the root dies, so descendants cannot be reparented out of reach.
|
|
for (const targetPid of [...readPosixDescendantPids(pid), pid].toReversed()) {
|
|
killPid(targetPid, 'SIGKILL')
|
|
}
|
|
}
|
|
}
|
|
await waitForExit(proc, PROCESS_EXIT_TIMEOUT_MS)
|
|
releaseExitedProcessPipes(proc)
|
|
// Playwright close can remain pending after an external force-kill.
|
|
await withTimeout(
|
|
app.close(),
|
|
PROCESS_EXIT_TIMEOUT_MS,
|
|
'Timed out releasing killed Electron app'
|
|
).catch(() => undefined)
|
|
}
|
|
|
|
// Stop serve like a supervisor so its detached PTYs survive; macOS vetoes unmarked app.quit and ordinary close may kill the daemon (#15537).
|
|
export async function stopServeElectronAppForE2E(app: ElectronApplication): Promise<void> {
|
|
const proc = app.process()
|
|
// Why: Windows has no catchable SIGTERM, and its serve has no native-quit veto to bypass.
|
|
if (process.platform === 'win32' || !proc.pid) {
|
|
await closeElectronAppForE2E(app)
|
|
return
|
|
}
|
|
if (!hasExited(proc)) {
|
|
proc.kill('SIGTERM')
|
|
}
|
|
if (!(await waitForExit(proc, GRACEFUL_CLOSE_TIMEOUT_MS))) {
|
|
throw new Error(`orca serve ${proc.pid} did not exit after SIGTERM`)
|
|
}
|
|
releaseExitedProcessPipes(proc)
|
|
// Playwright close can remain pending after the process exits on its own.
|
|
await withTimeout(
|
|
app.close(),
|
|
PROCESS_EXIT_TIMEOUT_MS,
|
|
'Timed out releasing stopped serve process'
|
|
).catch(() => undefined)
|
|
}
|
|
|
|
export async function closeElectronAppForE2E(
|
|
app: ElectronApplication,
|
|
options: { cleanupDaemonsForUserDataDir?: string } = {}
|
|
): Promise<void> {
|
|
const proc = app.process()
|
|
const started = performance.now()
|
|
let rootExitMs: number | null = hasExited(proc) ? 0 : null
|
|
let pipesClosedMs: number | null = null
|
|
let outcome = 'closed'
|
|
let daemonCleanup: Promise<void> | undefined
|
|
const startDaemonCleanup = (): void => {
|
|
if (options.cleanupDaemonsForUserDataDir && !daemonCleanup) {
|
|
daemonCleanup = cleanupE2EDaemons(options.cleanupDaemonsForUserDataDir)
|
|
void daemonCleanup.catch(() => undefined)
|
|
}
|
|
}
|
|
const releasePipes = (): void => {
|
|
if (hasExited(proc)) {
|
|
rootExitMs ??= performance.now() - started
|
|
// Linux daemon trees retain Chromium sockets that block Playwright's public close.
|
|
startDaemonCleanup()
|
|
}
|
|
releaseExitedProcessPipes(proc)
|
|
}
|
|
const pipesClosed = (): void => {
|
|
pipesClosedMs = performance.now() - started
|
|
}
|
|
proc.once('exit', releasePipes)
|
|
proc.once('close', pipesClosed)
|
|
releasePipes()
|
|
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 {
|
|
outcome = 'fallback'
|
|
if (proc) {
|
|
await forceKillProcessTree(proc)
|
|
}
|
|
} finally {
|
|
proc.off('exit', releasePipes)
|
|
proc.off('close', pipesClosed)
|
|
releasePipes()
|
|
recordElectronShutdownTiming('close', started, {
|
|
pid: proc.pid,
|
|
outcome,
|
|
rootExitMs,
|
|
pipesClosedMs
|
|
})
|
|
}
|
|
startDaemonCleanup()
|
|
await daemonCleanup
|
|
}
|
|
|
|
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
|
|
}
|
|
let raw: string
|
|
try {
|
|
raw = readFileSync(path.join(daemonDir, entry), 'utf8').trim()
|
|
} catch {
|
|
// Why: an exiting daemon removes its pid file between readdir and read.
|
|
continue
|
|
}
|
|
try {
|
|
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(raw)
|
|
if (Number.isInteger(pid)) {
|
|
pids.push(pid)
|
|
}
|
|
}
|
|
}
|
|
return pids
|
|
}
|
|
|
|
export async function cleanupE2EDaemons(userDataDir: string): Promise<void> {
|
|
// Ordinary quit retains daemon PTYs; disposable profiles must explicitly stop them to avoid leaked CI process trees.
|
|
const started = performance.now()
|
|
const pids = readDaemonPidFiles(userDataDir)
|
|
try {
|
|
for (const pid of pids) {
|
|
await forceKillPidTree(pid)
|
|
}
|
|
cleanupE2ECrashpad(userDataDir)
|
|
} finally {
|
|
recordElectronShutdownTiming('daemon-cleanup', started, { daemonCount: pids.length })
|
|
}
|
|
}
|