async-book-lock.spec.ts 3.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
  1. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  2. import * as fs from 'node:fs'
  3. import * as path from 'node:path'
  4. import * as os from 'node:os'
  5. import { withBookLock, withBookLockAsync, withBookWriteAsync, writeBatchAtomic } from '../src'
  6. import { removeSync } from '../src/repo/remove'
  7. let root: string
  8. beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-async-lock-')) })
  9. afterEach(() => { removeSync(root) })
  10. const lockPath = () => path.join(root, '.webnovel/book.lock')
  11. describe('等待原生写入时的书仓锁', () => {
  12. it('跨 await 保持独占,同一调用链可重入同步提交与原子事务', async () => {
  13. let release!: () => void
  14. const paused = new Promise<void>(resolve => { release = resolve })
  15. const task = withBookWriteAsync(root, async () => {
  16. await paused
  17. expect(fs.existsSync(lockPath())).toBe(true)
  18. await withBookLockAsync(root, async () => {
  19. withBookLock(root, () => writeBatchAtomic(root, [{ relPath: '大纲/故事骨架.md', content: '正文\n' }]))
  20. })
  21. expect(fs.existsSync(lockPath())).toBe(true)
  22. }, { operationId: 'async-native-write', callId: 'call-1' })
  23. try {
  24. const owner = fs.readFileSync(lockPath(), 'utf8')
  25. expect(() => withBookLock(root, () => undefined)).toThrow(/写入锁定中/)
  26. await expect(withBookLockAsync(root, async () => undefined)).rejects.toThrow(/写入锁定中/)
  27. expect(fs.readFileSync(lockPath(), 'utf8')).toBe(owner)
  28. } finally { release(); await task }
  29. expect(fs.existsSync(lockPath())).toBe(false)
  30. expect(fs.readFileSync(path.join(root, '大纲/故事骨架.md'), 'utf8')).toBe('正文\n')
  31. const audit = JSON.parse(fs.readFileSync(path.join(root, '.webnovel/operations/async-native-write.json'), 'utf8'))
  32. expect(audit).toMatchObject({ status: 'completed', callId: 'call-1', targets: ['大纲/故事骨架.md'] })
  33. })
  34. it.each(['throw', 'failure'] as const)('%s 时释放锁并保留失败审计,随后能重新写入', async mode => {
  35. const task = withBookLockAsync(root, async () => {
  36. await Promise.resolve()
  37. if (mode === 'throw') throw new Error('原生版本冲突')
  38. return { ok: false, reason: '原生版本冲突' }
  39. }, { operationId: `failure-${mode}` })
  40. if (mode === 'throw') await expect(task).rejects.toThrow('原生版本冲突')
  41. else await expect(task).resolves.toEqual({ ok: false, reason: '原生版本冲突' })
  42. expect(fs.existsSync(lockPath())).toBe(false)
  43. const audit = JSON.parse(fs.readFileSync(path.join(root, `.webnovel/operations/failure-${mode}.json`), 'utf8'))
  44. expect(audit.status).toBe('failed')
  45. expect(audit.error).toContain('原生版本冲突')
  46. expect(withBookLock(root, () => '再次写入')).toBe('再次写入')
  47. })
  48. it('同步锁中不能启动异步临界区', async () => {
  49. let nested!: Promise<void>
  50. withBookLock(root, () => { nested = withBookLockAsync(root, async () => undefined) })
  51. await expect(nested).rejects.toThrow(/同步书仓锁内不得启动异步写入/)
  52. expect(fs.existsSync(lockPath())).toBe(false)
  53. })
  54. })