skillhub/cli/test/unit/services/skill-target-lock.test.ts
XiaoSeS 1e5fe097fe
fix(cli): serialize concurrent stale lock recovery (#839)
* test(cli): reproduce concurrent stale lock ownership

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): wait for every stale lock contender

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): serialize stale target lock recovery

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): recover abandoned acquisition gates

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): cover acquisition gate crash recovery

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): keep live acquisition tickets

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): use crash-recoverable acquisition queue

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* perf(cli): minimize acquisition queue scans

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): stabilize acquisition queue election

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): verify stalled contenders retain ownership

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): wait for acquisition queue turn

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): recover stale target after gate owner exit

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): assert stale target cleanup after recovery

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

---------

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-09-09 13:43:33 +08:00

217 lines
8.9 KiB
TypeScript

import { access, chmod, lstat, mkdir, mkdtemp, readdir, symlink, unlink, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, test } from 'bun:test'
import {
acquireSkillTargetLock,
assertPrivateLockDir,
ensurePrivateLockDir,
skillTargetLockPath
} from '../../../src/services/skill-target-lock'
async function exists(path: string): Promise<boolean> {
try {
await access(path)
return true
} catch {
return false
}
}
async function waitForFile(path: string): Promise<void> {
for (let attempt = 0; attempt < 500; attempt++) {
if (await exists(path)) return
await Bun.sleep(10)
}
throw new Error(`timed out waiting for ${path}`)
}
async function waitForExitCount(
processes: Array<{ exited: Promise<number> }>,
count: number
): Promise<number[]> {
const exitCodes: number[] = []
for (const process of processes) {
void process.exited.then(exitCode => exitCodes.push(exitCode))
}
for (let attempt = 0; attempt < 500; attempt++) {
if (exitCodes.length >= count) return exitCodes
await Bun.sleep(10)
}
throw new Error(`timed out waiting for ${count} lock contenders to exit`)
}
describe('skill target lifecycle lock', () => {
test('creates or repairs a private lock root and rejects unsafe roots', async () => {
const parent = await mkdtemp(join(tmpdir(), 'skillhub-lock-root-'))
const privateRoot = join(parent, 'private')
await ensurePrivateLockDir(privateRoot)
if (process.platform !== 'win32') {
expect((await lstat(privateRoot)).mode & 0o077).toBe(0)
await chmod(privateRoot, 0o755)
await ensurePrivateLockDir(privateRoot)
expect((await lstat(privateRoot)).mode & 0o077).toBe(0)
}
const fileRoot = join(parent, 'file')
await writeFile(fileRoot, 'keep')
await expect(ensurePrivateLockDir(fileRoot)).rejects.toThrow('unsafe SkillHub CLI lock directory')
expect(await Bun.file(fileRoot).text()).toBe('keep')
const symlinkTarget = join(parent, 'symlink-target')
const symlinkRoot = join(parent, 'symlink')
await mkdir(symlinkTarget)
await symlink(symlinkTarget, symlinkRoot, 'dir')
await expect(ensurePrivateLockDir(symlinkRoot)).rejects.toThrow('unsafe SkillHub CLI lock directory')
expect((await lstat(symlinkRoot)).isSymbolicLink()).toBe(true)
expect(() => assertPrivateLockDir('/foreign', {
isDirectory: () => true,
isSymbolicLink: () => false,
uid: 2000,
mode: 0o40700
}, 1000)).toThrow('owned by another user')
})
test('simultaneous stale recovery admits exactly one owner across processes', async () => {
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-target-lock-root-'))
const lockPath = await skillTargetLockPath(rootDir, 'demo')
await mkdir(lockPath)
const staleTime = new Date(Date.now() - 60_000)
await utimes(lockPath, staleTime, staleTime)
const acquisitionGatePath = `${lockPath}.acquire`
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 startPath = join(rootDir, 'start')
const workerCount = 8
const readyPaths = Array.from({ length: workerCount }, (_, index) => join(rootDir, `ready-${index}`))
const processes = readyPaths.map(readyPath => Bun.spawn({
cmd: [bunPath, worker, rootDir, 'demo', readyPath, startPath, acquiredPath, releasePath],
stdout: 'pipe',
stderr: 'pipe'
}))
try {
await Promise.all(readyPaths.map(waitForFile))
await writeFile(startPath, 'start')
await waitForFile(acquiredPath)
const loserExitCodes = await waitForExitCount(processes, workerCount - 1)
expect(loserExitCodes).toEqual(Array(workerCount - 1).fill(4))
} finally {
try {
await writeFile(releasePath, 'release')
} finally {
const exited = await Promise.race([
Promise.all(processes.map(process => process.exited)).then(() => true),
Bun.sleep(5_000).then(() => false)
])
if (!exited) {
for (const process of processes) process.kill()
await Promise.all(processes.map(process => process.exited))
}
}
}
const results = await Promise.all(processes.map(async process => ({
exitCode: await process.exited,
stdout: (await new Response(process.stdout).text()).trim(),
stderr: (await new Response(process.stderr).text()).trim()
})))
expect(results.map(result => result.exitCode).sort()).toEqual([0, ...Array(workerCount - 1).fill(4)])
expect(results.filter(result => result.stdout === 'acquired')).toHaveLength(1)
for (const loser of results.filter(result => result.exitCode === 4)) {
expect(loser.stderr).toContain('install target is busy')
}
expect(await exists(lockPath)).toBe(false)
expect(await readdir(acquisitionGatePath)).toEqual([])
const releaseAfterContention = await acquireSkillTargetLock(rootDir, 'demo')
await releaseAfterContention()
if (process.platform !== 'win32') {
expect((await lstat(dirname(lockPath))).mode & 0o077).toBe(0)
}
}, 15_000)
test('does not recover a live acquisition contender solely because its file is old', async () => {
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-target-live-gate-'))
const lockPath = await skillTargetLockPath(rootDir, 'demo')
const acquisitionGatePath = `${lockPath}.acquire`
await mkdir(acquisitionGatePath)
const liveContenderPath = join(acquisitionGatePath, `choosing.${process.pid}-suspended`)
await writeFile(liveContenderPath, '')
const staleTime = new Date(Date.now() - 60_000)
await utimes(liveContenderPath, staleTime, staleTime)
const acquisition = acquireSkillTargetLock(rootDir, 'demo')
const state = await Promise.race([
acquisition.then(() => 'acquired', () => 'rejected'),
Bun.sleep(50).then(() => 'waiting')
])
expect(state).toBe('waiting')
expect(await exists(liveContenderPath)).toBe(true)
await unlink(liveContenderPath)
const release = await acquisition
await release()
})
test('does not pass a live acquisition ticket', async () => {
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-target-live-ticket-'))
const lockPath = await skillTargetLockPath(rootDir, 'demo')
const acquisitionGatePath = `${lockPath}.acquire`
await mkdir(acquisitionGatePath)
const liveTicketPath = join(acquisitionGatePath, `ticket.1.${process.pid}-suspended`)
await writeFile(liveTicketPath, '')
const acquisition = acquireSkillTargetLock(rootDir, 'demo')
const state = await Promise.race([
acquisition.then(() => 'acquired', () => 'rejected'),
Bun.sleep(50).then(() => 'waiting')
])
expect(state).toBe('waiting')
expect(await exists(liveTicketPath)).toBe(true)
await unlink(liveTicketPath)
const release = await acquisition
await release()
})
test('recovers acquisition contenders whose owner process exited', async () => {
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-target-dead-gate-'))
const lockPath = await skillTargetLockPath(rootDir, 'demo')
await mkdir(lockPath)
const staleTime = new Date(Date.now() - 60_000)
await utimes(lockPath, staleTime, staleTime)
const acquisitionGatePath = `${lockPath}.acquire`
await mkdir(acquisitionGatePath)
const bunPath = (await Bun.which('bun')) ?? process.execPath
const exitedOwner = Bun.spawn({ cmd: [bunPath, '-e', ''], stdout: 'ignore', stderr: 'ignore' })
const deadPid = exitedOwner.pid
expect(await exitedOwner.exited).toBe(0)
await writeFile(join(acquisitionGatePath, `choosing.${deadPid}-abandoned`), '')
await writeFile(join(acquisitionGatePath, `ticket.1.${deadPid}-abandoned`), '')
const release = await acquireSkillTargetLock(rootDir, 'demo')
await release()
expect(await exists(lockPath)).toBe(false)
expect(await readdir(acquisitionGatePath)).toEqual([])
})
test('keeps one lock identity when a symlink target is removed', async () => {
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-target-symlink-root-'))
const linkedDir = await mkdtemp(join(tmpdir(), 'skillhub-target-symlink-value-'))
const skillDir = join(rootDir, 'demo')
await symlink(linkedDir, skillDir, process.platform === 'win32' ? 'junction' : 'dir')
const lockPathBeforeRemoval = await skillTargetLockPath(rootDir, 'demo')
const release = await acquireSkillTargetLock(rootDir, 'demo')
try {
await unlink(skillDir)
expect(await skillTargetLockPath(rootDir, 'demo')).toBe(lockPathBeforeRemoval)
await expect(acquireSkillTargetLock(rootDir, 'demo')).rejects.toThrow('install target is busy')
} finally {
await release()
}
expect(await exists(lockPathBeforeRemoval)).toBe(false)
})
})