transaction-process.spec.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. import { afterAll, beforeAll, 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 { fileURLToPath, pathToFileURL } from 'node:url'
  6. import { spawn, spawnSync } from 'node:child_process'
  7. import { createRequire } from 'node:module'
  8. import { withBookWrite, writeBatchAtomic, writeContract, paths } from '../src'
  9. import { removeSync } from '../src/repo/remove'
  10. const roots: string[] = []
  11. function root(): string {
  12. const value = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-process-'))
  13. roots.push(value)
  14. return value
  15. }
  16. let moduleUrl: string
  17. beforeAll(() => {
  18. const localRequire = createRequire(import.meta.url)
  19. const vitestRequire = createRequire(localRequire.resolve('vitest/package.json'))
  20. const viteRequire = createRequire(vitestRequire.resolve('vite/package.json'))
  21. const { buildSync } = viteRequire('esbuild') as { buildSync(options: Record<string, unknown>): unknown }
  22. const outfile = path.join(root(), 'atomic.mjs')
  23. buildSync({ entryPoints: [fileURLToPath(new URL('../src/repo/atomic.ts', import.meta.url))], outfile, bundle: true, platform: 'node', format: 'esm' })
  24. moduleUrl = pathToFileURL(outfile).href
  25. })
  26. afterAll(() => { for (const value of roots.reverse()) removeSync(value) })
  27. const childSource = `
  28. import fs from 'node:fs';
  29. import path from 'node:path';
  30. import { syncBuiltinESMExports } from 'node:module';
  31. const [url, root, mode, count, rawOps] = process.argv.slice(1);
  32. const rename = fs.renameSync;
  33. let applied = 0;
  34. fs.renameSync = (from, to) => {
  35. const result = rename(from, to);
  36. if (mode === 'rename' && path.basename(String(from)).startsWith('.webnovel-txn-') && ++applied === Number(count)) process.exit(88);
  37. if (mode === 'commit' && path.basename(String(to)) === 'manifest.json' && JSON.parse(fs.readFileSync(to, 'utf8')).phase === 'committed') process.exit(88);
  38. return result;
  39. };
  40. syncBuiltinESMExports();
  41. const { withBookWrite, writeBatchAtomic } = await import(url);
  42. withBookWrite(root, () => {
  43. if (mode === 'hold') {
  44. fs.writeFileSync(path.join(root, 'ready'), '');
  45. const end = Date.now() + 10000;
  46. while (!fs.existsSync(path.join(root, 'release')) && Date.now() < end) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
  47. return;
  48. }
  49. writeBatchAtomic(root, JSON.parse(rawOps));
  50. }, { operationId: 'process-operation', sessionId: 'process-session', callId: 'process-call' });
  51. `
  52. const ops = [
  53. { relPath: '正文/a.md', content: 'new-a' },
  54. { relPath: '正文/b.md', content: 'new-b' },
  55. { relPath: '正文/c.md', content: 'new-c' },
  56. ]
  57. function fixture(): string {
  58. const value = root()
  59. fs.mkdirSync(path.join(value, '正文'))
  60. fs.writeFileSync(path.join(value, '正文/a.md'), 'old-a')
  61. fs.writeFileSync(path.join(value, '正文/b.md'), 'old-b')
  62. return value
  63. }
  64. function crash(value: string, mode: string, count: number, files = ops): void {
  65. const child = spawnSync(process.execPath, ['--input-type=module', '-e', childSource, moduleUrl, value, mode, String(count), JSON.stringify(files)], { encoding: 'utf8', windowsHide: true, timeout: 10000 })
  66. expect(child.status, child.stderr).toBe(88)
  67. }
  68. describe('real process interruption', () => {
  69. it.each([1, 2, 3])('recovers before a writer reads after target rename %s', (count) => {
  70. const value = fixture()
  71. crash(value, 'rename', count)
  72. const txs = path.join(value, '.webnovel/transactions')
  73. const manifest = JSON.parse(fs.readFileSync(path.join(txs, fs.readdirSync(txs)[0]!, 'manifest.json'), 'utf8'))
  74. expect(manifest.provenance).toMatchObject({ operationId: 'process-operation', sessionId: 'process-session', callId: 'process-call' })
  75. withBookWrite(value, () => {
  76. expect(fs.readFileSync(path.join(value, '正文/a.md'), 'utf8')).toBe('old-a')
  77. expect(fs.readFileSync(path.join(value, '正文/b.md'), 'utf8')).toBe('old-b')
  78. expect(fs.existsSync(path.join(value, '正文/c.md'))).toBe(false)
  79. })
  80. expect(fs.readdirSync(txs)).toEqual([])
  81. expect(fs.readdirSync(path.join(value, '正文')).sort()).toEqual(['a.md', 'b.md'])
  82. })
  83. it('keeps every committed target when the process exits before cleanup', () => {
  84. const value = fixture()
  85. crash(value, 'commit', 0)
  86. withBookWrite(value, () => {
  87. for (const op of ops) expect(fs.readFileSync(path.join(value, op.relPath), 'utf8')).toBe(op.content)
  88. })
  89. expect(fs.readdirSync(path.join(value, '.webnovel/transactions'))).toEqual([])
  90. })
  91. it('retains all evidence and all targets when any target conflicts', () => {
  92. const value = fixture()
  93. crash(value, 'rename', 2)
  94. fs.writeFileSync(path.join(value, '正文/b.md'), 'author-edit')
  95. expect(() => withBookWrite(value, () => 'unreachable')).toThrow(/恢复冲突/)
  96. expect(fs.readFileSync(path.join(value, '正文/a.md'), 'utf8')).toBe('new-a')
  97. expect(fs.readFileSync(path.join(value, '正文/b.md'), 'utf8')).toBe('author-edit')
  98. const tx = path.join(value, '.webnovel/transactions', fs.readdirSync(path.join(value, '.webnovel/transactions'))[0]!)
  99. expect(fs.existsSync(path.join(tx, 'manifest.json'))).toBe(true)
  100. expect(fs.readFileSync(path.join(tx, 'backups/0.bak'), 'utf8')).toBe('old-a')
  101. })
  102. it('public contract writer recovers its input before merging an update', () => {
  103. const value = root()
  104. writeContract(value, { 题材与读者定位: { state: '已确认', body: 'OLD-CONTRACT' } })
  105. const original = fs.readFileSync(path.join(value, paths.契约()), 'utf8')
  106. crash(value, 'rename', 1, [{ relPath: paths.契约(), content: original.replace('OLD-CONTRACT', 'CRASH-CONTRACT') }])
  107. writeContract(value, { 核心看点与差异化: { state: '已确认', body: 'NEW-PART' } })
  108. const result = fs.readFileSync(path.join(value, paths.契约()), 'utf8')
  109. expect(result).toContain('OLD-CONTRACT')
  110. expect(result).toContain('NEW-PART')
  111. expect(result).not.toContain('CRASH-CONTRACT')
  112. })
  113. it('refuses a real live writer in another process', async () => {
  114. const value = root()
  115. const child = spawn(process.execPath, ['--input-type=module', '-e', childSource, moduleUrl, value, 'hold', '0', '[]'], { windowsHide: true, stdio: 'ignore' })
  116. const exited = new Promise<void>((resolve, reject) => {
  117. child.once('error', reject)
  118. child.once('exit', () => resolve())
  119. })
  120. try {
  121. const end = Date.now() + 5000
  122. while (!fs.existsSync(path.join(value, 'ready')) && Date.now() < end) await new Promise(resolve => setTimeout(resolve, 10))
  123. expect(fs.existsSync(path.join(value, 'ready'))).toBe(true)
  124. expect(() => writeBatchAtomic(value, [{ relPath: 'blocked.md', content: 'blocked' }])).toThrow(/锁定中/)
  125. expect(fs.existsSync(path.join(value, 'blocked.md'))).toBe(false)
  126. } finally {
  127. fs.writeFileSync(path.join(value, 'release'), '')
  128. await exited
  129. }
  130. })
  131. it('uses one lock for a canonical root and a junction alias', () => {
  132. const value = root()
  133. const alias = path.join(root(), 'alias')
  134. fs.symlinkSync(value, alias, process.platform === 'win32' ? 'junction' : 'dir')
  135. withBookWrite(alias, () => writeBatchAtomic(value, [{ relPath: 'alias.md', content: 'ok' }]))
  136. expect(fs.readFileSync(path.join(value, 'alias.md'), 'utf8')).toBe('ok')
  137. })
  138. })