fix(codex): bound validation harness shutdown

This commit is contained in:
Brennan Benson
2026-07-17 13:47:06 -07:00
parent f9aa83d8e5
commit df38f66ddf
2 changed files with 239 additions and 23 deletions
@@ -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)
}
}
}