mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
test(skills): prove multi-process lock contention
This commit is contained in:
@@ -1316,8 +1316,10 @@ platform gates below.
|
||||
The shared transaction core now serializes two simultaneous installs into one installed and
|
||||
one unchanged result, one receipt, and no staging residue. The host request service also
|
||||
serializes simultaneous download-grant and trusted-local ingress requests with the same
|
||||
guarantees; contention across separate desktop, headless, CLI, SSH, and recovery processes
|
||||
remains open.
|
||||
guarantees. A real multi-process harness proves an on-disk destination lock returns a
|
||||
retryable busy result without residue, then converges to unchanged after the owning process
|
||||
commits and releases; the release gate runs it on macOS, native Windows, and Ubuntu 20.04.
|
||||
Named desktop/headless/CLI/SSH/recovery process combinations remain open.
|
||||
- [ ] Test permission and read-only failures, disk exhaustion, cancellation, process termination,
|
||||
and host disconnect. Simulated `EACCES` and `ENOSPC` preservation plus lost-response retry are
|
||||
covered; physical failure and termination journeys remain open.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { stat, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { describe, it } from 'vitest'
|
||||
import { installLocalSkillPackage } from './skill-install-transaction'
|
||||
|
||||
const CHILD = process.env.ORCA_SKILL_CONTENTION_CHILD === '1'
|
||||
|
||||
async function waitForRelease(path: string): Promise<void> {
|
||||
const deadline = Date.now() + 15_000
|
||||
while (Date.now() < deadline) {
|
||||
if (await stat(path).catch(() => null)) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 20))
|
||||
}
|
||||
throw new Error('skill-contention-release-timeout')
|
||||
}
|
||||
|
||||
describe.runIf(CHILD)('skill process contention child', () => {
|
||||
it('runs one transaction process', async () => {
|
||||
const root = process.env.ORCA_SKILL_CONTENTION_ROOT
|
||||
const archivePath = process.env.ORCA_SKILL_CONTENTION_ARCHIVE
|
||||
const resultPath = process.env.ORCA_SKILL_CONTENTION_RESULT
|
||||
const role = process.env.ORCA_SKILL_CONTENTION_ROLE
|
||||
if (!root || !archivePath || !resultPath || !role) {
|
||||
throw new Error('missing-skill-contention-input')
|
||||
}
|
||||
const result = await installLocalSkillPackage(
|
||||
{
|
||||
operationId: `contention-${role}`,
|
||||
archivePath,
|
||||
destinationRoot: join(root, 'skills'),
|
||||
stateDirectory: join(root, 'state'),
|
||||
scope: 'global',
|
||||
destinationIdentity: 'global:contention-test',
|
||||
hostIdentity: 'contention-test',
|
||||
lockTimeoutMs: role === 'blocked' ? 1 : undefined
|
||||
},
|
||||
{
|
||||
onJournalTransition: async (phase, boundary) => {
|
||||
if (role !== 'holder' || phase !== 'prepared' || boundary !== 'before') {
|
||||
return
|
||||
}
|
||||
const readyPath = process.env.ORCA_SKILL_CONTENTION_READY
|
||||
const releasePath = process.env.ORCA_SKILL_CONTENTION_RELEASE
|
||||
if (!readyPath || !releasePath) {
|
||||
throw new Error('missing-skill-contention-coordination')
|
||||
}
|
||||
await writeFile(readyPath, `${process.pid}\n`)
|
||||
await waitForRelease(releasePath)
|
||||
}
|
||||
}
|
||||
)
|
||||
await writeFile(resultPath, JSON.stringify(result))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createSkillPackageArchive } from './skill-package-creation'
|
||||
|
||||
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-process-contention-child.test.ts')
|
||||
const roots: string[] = []
|
||||
const children: ChildProcess[] = []
|
||||
|
||||
function childOutput(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 waitForFile(path: string, child: ChildProcess, output: () => string): Promise<void> {
|
||||
const deadline = Date.now() + 15_000
|
||||
while (Date.now() < deadline) {
|
||||
if (await stat(path).catch(() => null)) {
|
||||
return
|
||||
}
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`skill-contention-child-exited-${child.exitCode}: ${output()}`)
|
||||
}
|
||||
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 20))
|
||||
}
|
||||
throw new Error(`skill-contention-marker-timeout: ${output()}`)
|
||||
}
|
||||
|
||||
function startChild(input: {
|
||||
root: string
|
||||
archivePath: string
|
||||
role: 'holder' | 'blocked' | 'retry'
|
||||
readyPath: string
|
||||
releasePath: string
|
||||
resultPath: string
|
||||
}): { child: ChildProcess; output: () => string } {
|
||||
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_CONTENTION_CHILD: '1',
|
||||
ORCA_SKILL_CONTENTION_ROOT: input.root,
|
||||
ORCA_SKILL_CONTENTION_ARCHIVE: input.archivePath,
|
||||
ORCA_SKILL_CONTENTION_ROLE: input.role,
|
||||
ORCA_SKILL_CONTENTION_READY: input.readyPath,
|
||||
ORCA_SKILL_CONTENTION_RELEASE: input.releasePath,
|
||||
ORCA_SKILL_CONTENTION_RESULT: input.resultPath
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
children.push(child)
|
||||
return { child, output: childOutput(child).value }
|
||||
}
|
||||
|
||||
async function waitForExit(child: ChildProcess, output: () => string): Promise<void> {
|
||||
const exit =
|
||||
child.exitCode !== null || child.signalCode !== null
|
||||
? { code: child.exitCode, signal: child.signalCode }
|
||||
: await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolveExit) =>
|
||||
child.once('exit', (code, signal) => resolveExit({ code, signal }))
|
||||
)
|
||||
if (exit.code !== 0) {
|
||||
throw new Error(`skill-contention-child-failed-${exit.code ?? exit.signal}: ${output()}`)
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const child of children.splice(0)) {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
}
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe.runIf(RUN_REAL_PROCESS)('skill multi-process contention', () => {
|
||||
it('fails busy without residue, then converges after the owner releases', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-process-contention-'))
|
||||
roots.push(root)
|
||||
const source = join(root, 'source')
|
||||
await mkdir(source)
|
||||
await writeFile(
|
||||
join(source, 'SKILL.md'),
|
||||
'---\nname: contention-skill\ndescription: Contention\n---\n\n# Contention\n'
|
||||
)
|
||||
const archive = await createSkillPackageArchive({
|
||||
sourceDirectory: source,
|
||||
archivePath: join(root, 'package.tar.gz'),
|
||||
packageId: 'package-contention',
|
||||
versionId: 'version_1'
|
||||
})
|
||||
const readyPath = join(root, 'holder-ready')
|
||||
const releasePath = join(root, 'holder-release')
|
||||
const holderResultPath = join(root, 'holder-result.json')
|
||||
const blockedResultPath = join(root, 'blocked-result.json')
|
||||
const retryResultPath = join(root, 'retry-result.json')
|
||||
const holder = startChild({
|
||||
root,
|
||||
archivePath: archive.archivePath,
|
||||
role: 'holder',
|
||||
readyPath,
|
||||
releasePath,
|
||||
resultPath: holderResultPath
|
||||
})
|
||||
await waitForFile(readyPath, holder.child, holder.output)
|
||||
|
||||
const blocked = startChild({
|
||||
root,
|
||||
archivePath: archive.archivePath,
|
||||
role: 'blocked',
|
||||
readyPath,
|
||||
releasePath,
|
||||
resultPath: blockedResultPath
|
||||
})
|
||||
await waitForExit(blocked.child, blocked.output)
|
||||
expect(JSON.parse(await readFile(blockedResultPath, 'utf8'))).toMatchObject({
|
||||
status: 'failed',
|
||||
errorCategory: 'skill-install-busy',
|
||||
failure: { retryable: true }
|
||||
})
|
||||
|
||||
await writeFile(releasePath, 'release\n')
|
||||
await waitForExit(holder.child, holder.output)
|
||||
expect(JSON.parse(await readFile(holderResultPath, 'utf8'))).toMatchObject({
|
||||
status: 'installed'
|
||||
})
|
||||
|
||||
const retry = startChild({
|
||||
root,
|
||||
archivePath: archive.archivePath,
|
||||
role: 'retry',
|
||||
readyPath,
|
||||
releasePath,
|
||||
resultPath: retryResultPath
|
||||
})
|
||||
await waitForExit(retry.child, retry.output)
|
||||
expect(JSON.parse(await readFile(retryResultPath, 'utf8'))).toMatchObject({
|
||||
status: 'unchanged'
|
||||
})
|
||||
expect(await readdir(join(root, 'state', 'receipts'))).toHaveLength(1)
|
||||
expect((await readdir(join(root, 'skills'))).filter((name) => name.includes('.orca-'))).toEqual(
|
||||
[]
|
||||
)
|
||||
}, 30_000)
|
||||
})
|
||||
Reference in New Issue
Block a user