diff --git a/cli/test/helpers/target-lock-worker.ts b/cli/test/helpers/target-lock-worker.ts index 911863f5..58f4573a 100644 --- a/cli/test/helpers/target-lock-worker.ts +++ b/cli/test/helpers/target-lock-worker.ts @@ -1,13 +1,22 @@ +import { access, writeFile } from 'node:fs/promises' import { acquireSkillTargetLock } from '../../src/services/skill-target-lock' import { CliError } from '../../src/shared/errors' -const [rootDir, slug, holdMilliseconds] = process.argv.slice(2) -if (!rootDir || !slug || !holdMilliseconds) process.exit(5) +const [rootDir, slug, acquiredPath, releasePath] = process.argv.slice(2) +if (!rootDir || !slug || !acquiredPath || !releasePath) process.exit(5) try { const release = await acquireSkillTargetLock(rootDir, slug) + await writeFile(acquiredPath, 'acquired') process.stdout.write('acquired\n') - await new Promise(resolve => setTimeout(resolve, Number(holdMilliseconds))) + while (true) { + try { + await access(releasePath) + break + } catch { + await new Promise(resolve => setTimeout(resolve, 10)) + } + } await release() process.exit(0) } catch (error) { diff --git a/cli/test/unit/services/skill-target-lock.test.ts b/cli/test/unit/services/skill-target-lock.test.ts index 558d7141..ce64658b 100644 --- a/cli/test/unit/services/skill-target-lock.test.ts +++ b/cli/test/unit/services/skill-target-lock.test.ts @@ -1,4 +1,4 @@ -import { access, mkdir, mkdtemp, utimes } from 'node:fs/promises' +import { access, mkdir, mkdtemp, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -14,6 +14,14 @@ async function exists(path: string): Promise { } } +async function waitForFile(path: string): Promise { + for (let attempt = 0; attempt < 500; attempt++) { + if (await exists(path)) return + await Bun.sleep(10) + } + throw new Error(`timed out waiting for ${path}`) +} + describe('skill target lifecycle lock', () => { test('simultaneous stale recovery admits exactly one owner across processes', async () => { const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-target-lock-root-')) @@ -23,12 +31,21 @@ describe('skill target lifecycle lock', () => { await utimes(lockPath, staleTime, staleTime) const worker = fileURLToPath(new URL('../../helpers/target-lock-worker.ts', import.meta.url)) const bunPath = (await Bun.which('bun')) ?? process.execPath + const acquiredPath = join(rootDir, 'acquired') + const releasePath = join(rootDir, 'release') const processes = [0, 1].map(() => Bun.spawn({ - cmd: [bunPath, worker, rootDir, 'demo', '1000'], + cmd: [bunPath, worker, rootDir, 'demo', acquiredPath, releasePath], stdout: 'pipe', stderr: 'pipe' })) + await waitForFile(acquiredPath) + const loserExitCode = await Promise.race([ + ...processes.map(process => process.exited), + Bun.sleep(5_000).then(() => { throw new Error('timed out waiting for the lock loser') }) + ]) + expect(loserExitCode).toBe(4) + await writeFile(releasePath, 'release') const results = await Promise.all(processes.map(async process => ({ exitCode: await process.exited, stdout: (await new Response(process.stdout).text()).trim(),