mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
fix(codex): bound validation harness shutdown
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const GRACEFUL_CLOSE_TIMEOUT_MS = 10_000
|
||||
const PROCESS_EXIT_TIMEOUT_MS = 5_000
|
||||
const FORCE_KILL_WAIT_MS = 2_000
|
||||
|
||||
function delay(ms) {
|
||||
// Why: after Electron dies, this may be the standalone harness's only active
|
||||
// handle; keeping it referenced ensures daemon cleanup reaches completion.
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function hasExited(childProcess) {
|
||||
return childProcess.exitCode !== null || childProcess.signalCode !== null
|
||||
}
|
||||
|
||||
function waitForExit(childProcess, timeoutMs) {
|
||||
if (hasExited(childProcess)) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const finish = (exited) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
childProcess.off('exit', onExit)
|
||||
childProcess.off('close', onExit)
|
||||
resolve(exited)
|
||||
}
|
||||
const onExit = () => finish(true)
|
||||
const timeout = setTimeout(() => finish(false), timeoutMs)
|
||||
childProcess.once('exit', onExit)
|
||||
childProcess.once('close', onExit)
|
||||
})
|
||||
}
|
||||
|
||||
async function withTimeout(promise, timeoutMs, message) {
|
||||
let timeout = null
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error(message)), timeoutMs)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readPosixDescendantPids(rootPid) {
|
||||
try {
|
||||
const output = execFileSync('ps', ['-eo', 'pid=,ppid='], { encoding: 'utf8' })
|
||||
const childrenByParent = new Map()
|
||||
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 = []
|
||||
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, signal) {
|
||||
try {
|
||||
process.kill(pid, signal)
|
||||
} catch {
|
||||
/* already dead or inaccessible */
|
||||
}
|
||||
}
|
||||
|
||||
async function forceKillPidTree(pid) {
|
||||
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: a detached validation daemon can outlive Electron and retain access
|
||||
// to the disposable credential root unless the whole tree is captured first.
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeValidationElectronApp(app) {
|
||||
if (!app) {
|
||||
return
|
||||
}
|
||||
|
||||
const childProcess = app.process()
|
||||
try {
|
||||
await withTimeout(
|
||||
app.close(),
|
||||
GRACEFUL_CLOSE_TIMEOUT_MS,
|
||||
'Timed out closing validation Electron app'
|
||||
)
|
||||
if (childProcess) {
|
||||
const exited = await waitForExit(childProcess, PROCESS_EXIT_TIMEOUT_MS)
|
||||
if (!exited) {
|
||||
await forceKillPidTree(childProcess.pid)
|
||||
await waitForExit(childProcess, PROCESS_EXIT_TIMEOUT_MS)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (childProcess && !hasExited(childProcess)) {
|
||||
await forceKillPidTree(childProcess.pid)
|
||||
await waitForExit(childProcess, PROCESS_EXIT_TIMEOUT_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readValidationDaemonPids(userDataDir) {
|
||||
const daemonDir = path.join(userDataDir, 'daemon')
|
||||
if (!existsSync(daemonDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const pids = []
|
||||
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)
|
||||
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 cleanupValidationDaemons(userDataDir) {
|
||||
// Why: Orca intentionally keeps its daemon warm after app quit, but this
|
||||
// harness must never leave a process holding disposable account credentials.
|
||||
for (const pid of readValidationDaemonPids(userDataDir)) {
|
||||
await forceKillPidTree(pid)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@ import process from 'node:process'
|
||||
import readline from 'node:readline/promises'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { startCodexPrimaryHomeTripwire } from './codex-primary-home-tripwire.mjs'
|
||||
import {
|
||||
cleanupValidationDaemons,
|
||||
closeValidationElectronApp
|
||||
} from './codex-validation-process-shutdown.mjs'
|
||||
|
||||
const RESTRICTED_ENV_KEYS = [
|
||||
'HOME',
|
||||
@@ -223,6 +227,7 @@ function parseArgs(argv) {
|
||||
const options = {
|
||||
scenario: 'mixed',
|
||||
dryRun: false,
|
||||
closeAfterLaunch: false,
|
||||
skipBuild: false,
|
||||
keep: false,
|
||||
reportPath: null,
|
||||
@@ -249,13 +254,15 @@ function parseArgs(argv) {
|
||||
options.configTemplate = path.resolve(readValue())
|
||||
} else if (arg === '--dry-run') {
|
||||
options.dryRun = true
|
||||
} else if (arg === '--close-after-launch') {
|
||||
options.closeAfterLaunch = true
|
||||
} else if (arg === '--skip-build') {
|
||||
options.skipBuild = true
|
||||
} else if (arg === '--keep') {
|
||||
options.keep = true
|
||||
} else if (arg === '--help') {
|
||||
console.log(
|
||||
'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template <path>] [--skip-build] [--dry-run] [--keep] [--report <path>]'
|
||||
'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template <path>] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--report <path>]'
|
||||
)
|
||||
process.exit(0)
|
||||
} else {
|
||||
@@ -329,13 +336,23 @@ async function writeReport(reportPath, report) {
|
||||
}
|
||||
|
||||
async function runInteractiveSession(context) {
|
||||
if (process.stdin.readableEnded) {
|
||||
return
|
||||
}
|
||||
const prompt = readline.createInterface({ input: process.stdin, output: process.stdout })
|
||||
const closePrompt = () => prompt.close()
|
||||
// Why: redirected stdin can close before `question()` consumes a command.
|
||||
// Race the prompt so EOF still unwinds through app and credential cleanup.
|
||||
const inputClosed = new Promise((resolve) => prompt.once('close', () => resolve(null)))
|
||||
context.signal.addEventListener('abort', closePrompt, { once: true })
|
||||
console.log('Commands: checkpoint <label>, probe <terminal-handle>, status, done')
|
||||
try {
|
||||
while (!context.signal.aborted) {
|
||||
const line = (await prompt.question('codex-validation> ')).trim()
|
||||
const answer = await Promise.race([prompt.question('codex-validation> '), inputClosed])
|
||||
if (answer === null) {
|
||||
return
|
||||
}
|
||||
const line = answer.trim()
|
||||
const [command, ...rest] = line.split(/\s+/)
|
||||
if (command === 'checkpoint') {
|
||||
const label = rest.join(' ') || `checkpoint-${context.report.checkpoints.length + 1}`
|
||||
@@ -389,6 +406,9 @@ async function main() {
|
||||
let app = null
|
||||
let tripwire = null
|
||||
const abortController = new AbortController()
|
||||
const abortForSignal = () => abortController.abort()
|
||||
process.once('SIGINT', abortForSignal)
|
||||
process.once('SIGTERM', abortForSignal)
|
||||
const report = {
|
||||
scenario: options.scenario,
|
||||
startedAt: new Date().toISOString(),
|
||||
@@ -411,7 +431,6 @@ async function main() {
|
||||
console.error('\u001b[31;1m[VALIDATION ABORTED] Primary ~/.codex changed\u001b[0m')
|
||||
console.error(JSON.stringify(event, null, 2))
|
||||
abortController.abort()
|
||||
void app?.close()
|
||||
}
|
||||
})
|
||||
report.checkpoints.push({
|
||||
@@ -441,29 +460,40 @@ async function main() {
|
||||
}
|
||||
app.process().once('exit', () => abortController.abort())
|
||||
await writeReport(reportPath, report)
|
||||
await runInteractiveSession({
|
||||
layout,
|
||||
launchEnv,
|
||||
report,
|
||||
reportPath,
|
||||
tripwire,
|
||||
signal: abortController.signal
|
||||
})
|
||||
if (!options.closeAfterLaunch) {
|
||||
await runInteractiveSession({
|
||||
layout,
|
||||
launchEnv,
|
||||
report,
|
||||
reportPath,
|
||||
tripwire,
|
||||
signal: abortController.signal
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
abortController.abort()
|
||||
await app?.close().catch(() => {})
|
||||
if (tripwire) {
|
||||
report.tripwire = await tripwire.stop()
|
||||
}
|
||||
report.checkpoints.push({ label: 'shutdown', ...(await snapshotValidationState(layout)) })
|
||||
report.completedAt = new Date().toISOString()
|
||||
await writeReport(reportPath, report)
|
||||
if (options.keep) {
|
||||
console.warn(`Credential-bearing disposable root kept at ${layout.tempRoot}`)
|
||||
} else {
|
||||
await rm(layout.tempRoot, { recursive: true })
|
||||
console.log('Removed the credential-bearing disposable root.')
|
||||
try {
|
||||
await closeValidationElectronApp(app)
|
||||
await cleanupValidationDaemons(layout.userDataDir)
|
||||
if (tripwire) {
|
||||
report.tripwire = await tripwire.stop()
|
||||
}
|
||||
report.checkpoints.push({ label: 'shutdown', ...(await snapshotValidationState(layout)) })
|
||||
report.completedAt = new Date().toISOString()
|
||||
await writeReport(reportPath, report)
|
||||
} finally {
|
||||
try {
|
||||
if (options.keep) {
|
||||
console.warn(`Credential-bearing disposable root kept at ${layout.tempRoot}`)
|
||||
} else {
|
||||
await rm(layout.tempRoot, { recursive: true })
|
||||
console.log('Removed the credential-bearing disposable root.')
|
||||
}
|
||||
} finally {
|
||||
process.removeListener('SIGINT', abortForSignal)
|
||||
process.removeListener('SIGTERM', abortForSignal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user