mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-25 01:04:14 +00:00
* 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>
217 lines
8.9 KiB
TypeScript
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)
|
|
})
|
|
})
|