mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
176 lines
5.9 KiB
TypeScript
176 lines
5.9 KiB
TypeScript
import { spawn, type ChildProcess } from 'node:child_process'
|
|
import { createRequire } from 'node:module'
|
|
import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { recoverPendingSkillTransactions } from './skill-transaction-startup-recovery'
|
|
|
|
const RUN_REAL_PROCESS = process.env.ORCA_REAL_PROCESS_SKILL_TEST === '1'
|
|
const require = createRequire(import.meta.url)
|
|
const vitestBin = join(dirname(require.resolve('vitest/package.json')), 'vitest.mjs')
|
|
const childTest = resolve('src/main/skills/skill-bundle-process-termination-child.test.ts')
|
|
const roots: string[] = []
|
|
|
|
function boundedOutput(child: ChildProcess): { value: () => string } {
|
|
let output = ''
|
|
const append = (chunk: Buffer): void => {
|
|
output = `${output}${chunk.toString('utf8')}`.slice(-8_192)
|
|
}
|
|
child.stdout?.on('data', append)
|
|
child.stderr?.on('data', append)
|
|
return { value: () => output }
|
|
}
|
|
|
|
async function waitForPid(
|
|
path: string,
|
|
child: ChildProcess,
|
|
output: () => string
|
|
): Promise<number> {
|
|
const deadline = Date.now() + 30_000
|
|
while (Date.now() < deadline) {
|
|
const marker = await readFile(path, 'utf8').catch(() => '')
|
|
try {
|
|
const parsed: unknown = JSON.parse(marker)
|
|
const pid =
|
|
parsed && typeof parsed === 'object' && 'pid' in parsed
|
|
? (parsed as { pid?: unknown }).pid
|
|
: null
|
|
if (typeof pid === 'number' && Number.isInteger(pid) && pid > 0) {
|
|
return pid
|
|
}
|
|
} catch {
|
|
// The synced marker may not be fully visible on its first read.
|
|
}
|
|
if (child.exitCode !== null) {
|
|
throw new Error(`bundle-crash-child-exited-${child.exitCode}: ${output()}`)
|
|
}
|
|
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 10))
|
|
}
|
|
throw new Error(`bundle-crash-marker-timeout: ${output()}`)
|
|
}
|
|
|
|
async function waitForActiveExtraction(stateDirectory: string): Promise<string> {
|
|
const directory = join(stateDirectory, 'extraction-journals')
|
|
const deadline = Date.now() + 10_000
|
|
while (Date.now() < deadline) {
|
|
const names = await readdir(directory).catch(() => [])
|
|
const journalName = names.find((name) => name.endsWith('.json'))
|
|
if (journalName) {
|
|
const journal = JSON.parse(await readFile(join(directory, journalName), 'utf8')) as {
|
|
extractionPath?: unknown
|
|
}
|
|
if (typeof journal.extractionPath === 'string') {
|
|
const entries = await readdir(journal.extractionPath).catch(() => [])
|
|
if (entries.length > 0) {
|
|
return journal.extractionPath
|
|
}
|
|
}
|
|
}
|
|
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 5))
|
|
}
|
|
throw new Error('bundle-extraction-journal-timeout')
|
|
}
|
|
|
|
function processIsAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0)
|
|
return true
|
|
} catch (error) {
|
|
return (error as NodeJS.ErrnoException).code === 'EPERM'
|
|
}
|
|
}
|
|
|
|
async function waitForProcessExit(pid: number): Promise<void> {
|
|
const deadline = Date.now() + 5_000
|
|
while (Date.now() < deadline) {
|
|
if (!processIsAlive(pid)) {
|
|
return
|
|
}
|
|
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 20))
|
|
}
|
|
throw new Error(`bundle-crash-process-still-running-${pid}`)
|
|
}
|
|
|
|
async function waitForChildExit(child: ChildProcess): Promise<void> {
|
|
if (child.exitCode !== null || child.signalCode !== null) {
|
|
return
|
|
}
|
|
await new Promise<void>((resolveExit, reject) => {
|
|
const timeout = setTimeout(
|
|
() => reject(new Error('bundle-crash-coordinator-kill-failed')),
|
|
5_000
|
|
)
|
|
child.once('exit', () => {
|
|
clearTimeout(timeout)
|
|
resolveExit()
|
|
})
|
|
})
|
|
}
|
|
|
|
async function startChild(root: string): Promise<{ child: ChildProcess; marker: string }> {
|
|
const marker = join(root, 'crash-ready')
|
|
const child = spawn(
|
|
process.execPath,
|
|
[
|
|
vitestBin,
|
|
'run',
|
|
'--config',
|
|
resolve('config/vitest.config.ts'),
|
|
'--pool=forks',
|
|
'--maxWorkers=1',
|
|
'--no-file-parallelism',
|
|
childTest
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
env: {
|
|
...process.env,
|
|
ORCA_REAL_PROCESS_SKILL_TEST: '0',
|
|
ORCA_SKILL_BUNDLE_PROCESS_CHILD: '1',
|
|
ORCA_SKILL_BUNDLE_CRASH_ROOT: root,
|
|
ORCA_SKILL_BUNDLE_CRASH_MARKER: marker
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true
|
|
}
|
|
)
|
|
return { child, marker }
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
|
})
|
|
|
|
describe.runIf(RUN_REAL_PROCESS)('skill bundle process termination recovery', () => {
|
|
it('removes a partially extracted bundle and its durable journal after process death', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-skill-bundle-process-crash-'))
|
|
roots.push(root)
|
|
const stateDirectory = join(root, 'state', 'skill-installs')
|
|
const { child, marker } = await startChild(root)
|
|
const output = boundedOutput(child)
|
|
const transactionPid = await waitForPid(marker, child, output.value)
|
|
const extractionPath = await waitForActiveExtraction(stateDirectory)
|
|
|
|
process.kill(transactionPid, 'SIGKILL')
|
|
await waitForProcessExit(transactionPid)
|
|
if (child.exitCode === null && child.signalCode === null) {
|
|
child.kill('SIGKILL')
|
|
}
|
|
await waitForChildExit(child)
|
|
|
|
expect(await stat(extractionPath)).toBeTruthy()
|
|
const report = await recoverPendingSkillTransactions(stateDirectory)
|
|
|
|
expect(report.failures).toEqual([])
|
|
expect(report.orphanedExtractionsRecovered).toBe(1)
|
|
await expect(stat(extractionPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
await expect(readdir(join(stateDirectory, 'extraction-journals'))).resolves.toEqual([])
|
|
expect(
|
|
(await readdir(join(root, 'home', '.agents', 'skills')).catch(() => [])).filter((name) =>
|
|
name.includes('.orca-')
|
|
)
|
|
).toEqual([])
|
|
}, 45_000)
|
|
})
|